Compare commits

..

131 Commits

Author SHA1 Message Date
Mads Marquart
c5f447e20b Bump version: 1.9.0 → 1.9.1 2020-01-06 13:23:39 +01:00
Mads Marquart
b4d3769fd5 Fix MQTT error handling
- Fix "Out of memory" errors
- Fix typo
2020-01-06 13:14:07 +01:00
Mads Marquart
b199d597b2 Bump version: 1.8.3 → 1.9.0 2020-01-06 10:57:19 +01:00
Mads Marquart
debfb37a47 Merge pull request #494 from carpedm20/websocket-mqtt-support
Add MQTT over WebSockets support
2020-01-06 10:51:20 +01:00
Mads Marquart
67fd6ffdf6 Better document MQTT topics 2020-01-06 10:34:39 +01:00
Mads Marquart
e57265016e Skip NoOp events 2020-01-06 10:27:40 +01:00
Mads Marquart
cf4c22898c Add undocumented _onSeen callback
Mostly just to slowly document unknown events
2020-01-06 10:27:11 +01:00
Mads Marquart
3bb99541e7 Improve MQTT connection error reporting 2020-01-05 23:44:19 +01:00
Mads Marquart
8c367af0ff Fix Python 2.7 errors 2020-01-05 20:52:50 +01:00
Mads Marquart
bc1e3edf17 Small fixes
Handle more errors, and fix Client.stopListening
2020-01-05 20:29:44 +01:00
Mads Marquart
e488f4a7da Fix typing status parsing
Co-authored-by: Tulir Asokan <tulir@maunium.net>
2020-01-05 19:57:53 +01:00
Mads Marquart
afad38d8e1 Fix chat timestamp parsing 2020-01-05 19:57:53 +01:00
Mads Marquart
e9804d4184 Fix message parsing 2020-01-05 19:57:53 +01:00
Mads Marquart
a1b80a7abb Replace pull channel with MQTT setup 2020-01-05 19:57:53 +01:00
Mads Marquart
803bfa7084 Add proper MQTT error handling 2020-01-05 19:57:53 +01:00
Mads Marquart
d1cb866b44 Refactor MQTT listening 2020-01-05 19:57:52 +01:00
Mads Marquart
a298e0cf16 Refactor MQTT to do proper reconnecting 2020-01-05 14:56:01 +01:00
Mads Marquart
766b0125fb Refactor MQTT connecting, add sync token support 2020-01-05 00:31:58 +01:00
Mads Marquart
998fa43fb2 Refactor MQTT connecting 2020-01-04 23:18:20 +01:00
Mads Marquart
ecc6edac5a Fix message receiving in MQTT 2020-01-04 16:23:51 +01:00
Mads Marquart
ea518ba4c9 Add initial MQTT helper 2020-01-04 16:23:35 +01:00
Mads Marquart
ffdf4222bf Split ._parseMessage to reduce indentation 2019-12-15 16:24:17 +01:00
Mads Marquart
a97ef67411 Backport e348425 2019-12-15 15:26:53 +01:00
Mads Marquart
813219cd9c Bump version: 1.8.2 → 1.8.3 2019-09-08 15:59:29 +02:00
Asiel Díaz Benítez
bb1f7d9294 Fix mimetypes.guess_type (#471)
`mimetypes.guess_type` fails if the url is something like `http://example.com/file.zip?u=10`.

Backported from 6bffb66
2019-09-08 15:58:34 +02:00
Mads Marquart
3d28c958d3 Bump version: 1.8.1 → 1.8.2 2019-09-05 20:07:44 +02:00
Marco Gavelli
6b68916d74 Fix Python 2 only issue (str.split does not take keyword parameters)
Fixes #469
2019-09-05 20:02:51 +02:00
Mads Marquart
12e752e681 Bump version: 1.8.0 → 1.8.1 2019-08-28 19:21:39 +02:00
Mads Marquart
1f342d0c71 Move Client._getSendData into the Thread / Group models 2019-08-28 18:07:21 +02:00
Mads Marquart
5e86d4a48a Add method to convert a ThreadType to a subclass of Thread (e.g. Group) 2019-08-28 18:07:21 +02:00
Mads Marquart
0838f84859 Move most of Client._getSendData to State._do_send_request 2019-08-28 18:07:21 +02:00
Mads Marquart
abc938eacd Make State.fb_dtsg private 2019-08-28 18:07:21 +02:00
Mads Marquart
4d13cd2c0b Move body of Client._doSendRequest to State 2019-08-28 18:07:21 +02:00
Mads Marquart
8f8971c706 Move parts of Client._getSendData to Message._to_send_data 2019-08-28 18:07:21 +02:00
Mads Marquart
2703d9513a Move Client._client_id to State 2019-08-28 18:07:21 +02:00
Mads Marquart
3dce83de93 Move Client._upload to State 2019-08-28 18:07:21 +02:00
Mads Marquart
ef8e7d4251 Move user id handling to State 2019-08-28 18:07:21 +02:00
Mads Marquart
a131e1ae73 Move body of Client.graphql_requests to State._graphql_requests 2019-08-28 18:07:21 +02:00
Mads Marquart
84a86bd7bd Move body of Client._payload_post to State 2019-08-28 18:07:21 +02:00
Mads Marquart
adfb5886c9 Move body of Client._post to State 2019-08-28 18:07:21 +02:00
Mads Marquart
8d237ea4ef Move body of Client._get to State 2019-08-28 18:07:21 +02:00
Mads Marquart
513bc6eadf Move Client._do_refresh to State 2019-08-28 18:07:21 +02:00
Mads Marquart
856962af63 Bump version: 1.7.3 → 1.8.0 2019-08-28 10:58:46 +02:00
Mads Marquart
7c68a29181 Stop using Client.graphql_request internally 2019-07-25 23:32:17 +02:00
Mads Marquart
2f4e3f2bb1 Remove Client._generatePayload
Make Client._get and Client._post require a query input
2019-07-25 20:20:26 +02:00
Mads Marquart
0389b838bc Merge pull request #455 from carpedm20/add-spell-check
Add spell checking.

Use sphinxcontrib-spelling to fix documentation and docstring spelling errors.
2019-07-25 18:51:53 +02:00
Mads Marquart
441f53e382 Merge pull request #454 from carpedm20/google-style-docstrings
Google docstring style
2019-07-24 21:56:33 +02:00
Mads Marquart
83c45dcf40 Fix spelling / typesetting in various places 2019-07-24 16:18:15 +02:00
Mads Marquart
cc9d81a39e Fix spelling mistakes in documentation 2019-07-24 16:18:15 +02:00
Mads Marquart
edf14cfd84 Add and configure sphinxcontrib-spelling 2019-07-24 16:17:36 +02:00
Mads Marquart
ee79969eda Delete docs/robots.txt
Introduced in a2930b4, but I found out you could deprecate the doc url at /en/master/ using the ReadTheDocs web configuration
2019-07-24 16:15:31 +02:00
Mads Marquart
dbb20b1fdc Convert various directives to Google style sections 2019-07-24 13:45:33 +02:00
Mads Marquart
beee209249 Convert :return: / :rtype: roles to Returns sections 2019-07-24 13:45:33 +02:00
Mads Marquart
d6876ce13b Convert :raises: roles to Raises sections 2019-07-24 13:43:34 +02:00
Mads Marquart
ed05d16a31 Move :type: roles into the Args sections 2019-07-24 13:43:34 +02:00
Mads Marquart
3806f01d2f Convert :param: roles to Args sections 2019-07-24 13:43:30 +02:00
Mads Marquart
5b69ced1e8 Add ability to use Google style docstrings
Use and configure the `napoleon` Sphinx extension
2019-07-24 13:43:02 +02:00
Mads Marquart
6b07f1d8b9 Fix first line of docstrings
- Use the imperative sense
- Use trailing dot
- Omit leading newline
- Grammar / vocabulary fixes
2019-07-24 13:43:01 +02:00
Przemek
700cf14a50 Add fetchThreadImages (#434) 2019-07-24 13:40:00 +02:00
Mads Marquart
1b08243cd2 Fix TODO entries showing file paths of the build system 2019-07-24 00:33:55 +02:00
Mads Marquart
a0b978004c Bump version: 1.7.2 → 1.7.3 2019-07-20 17:09:03 +02:00
Mads Marquart
efc8776e70 Fix login check, close #446
Facebook changed something internally, so that the redirected url is no longer always "/home.php", but instead sometimes just "/"
2019-07-20 17:01:54 +02:00
Szczepan Wiśniowski
915f9a3782 Add heart reaction (#445) 2019-07-20 16:21:44 +02:00
Mads Marquart
e136d77ade Fix 2FA login error, closes #442, replaces #443 2019-07-20 16:00:32 +02:00
Mads Marquart
04aec15833 Fix documentation badge 2019-07-04 00:43:34 +02:00
Mads Marquart
dd5e1024db Bump version: 1.7.1 → 1.7.2 2019-07-04 00:34:11 +02:00
Mads Marquart
31d13f8fae Fix #441, introduced in bc551a6 2019-07-04 00:33:08 +02:00
Mads Marquart
19b4d929e2 Add bump2version (to avoid mistakes like pushing wrong tag names) 2019-07-04 00:25:05 +02:00
Mads Marquart
27e5d1baae Bump version: 1.7.0 -> 1.7.1 2019-07-03 23:59:45 +02:00
Mads Marquart
3a0b9867bc Merge pull request #440 from carpedm20/fix-docs
Fix and clean up documentation
2019-07-03 23:55:16 +02:00
Mads Marquart
a9c681818a Enable strict/explicit code highlighting 2019-07-03 23:42:32 +02:00
Mads Marquart
d279c96dd5 Make docs parsing "nitpicky" 2019-07-03 23:18:02 +02:00
Mads Marquart
d30589d1fa Add rst_prolog to docs/conf.py 2019-07-03 17:46:42 +02:00
Mads Marquart
47c744e5e2 Fix reST any roles/references 2019-07-03 17:35:38 +02:00
Mads Marquart
708869ea93 Include missing models in auto-generated API docs 2019-07-03 17:19:11 +02:00
Mads Marquart
8b47bf3e5d Add instructions for installing with pip > 19.0 2019-07-03 17:16:25 +02:00
Mads Marquart
a2930b4386 Deprecate the doc url at /en/master/ in favor of /en/latest/ 2019-07-03 17:15:21 +02:00
Mads Marquart
2dc93ed18b Add .readthedocs.yml 2019-07-03 15:18:11 +02:00
Mads Marquart
2bd08c8254 Update Sphinx to version 2.0 2019-07-03 14:22:09 +02:00
Mads Marquart
81278ed553 Remove doc configuration entries that are set to the default 2019-07-03 14:21:40 +02:00
Mads Marquart
589cec66e1 Refactor doc files to match format generated by sphinx-quickstart 2019-07-03 14:21:25 +02:00
Mads Marquart
281a20f56a Fix dependency pinning 2019-07-03 12:55:13 +02:00
Mads Marquart
ae8d205dbe Loosely pin dependencies 2019-07-03 11:17:53 +02:00
Mads Marquart
1e6222f46a Optimize BeautifulSoup input field parsing 2019-07-03 11:09:41 +02:00
Mads Marquart
4f2a24848e Use default black "exclude" string 2019-07-03 11:05:16 +02:00
Mads Marquart
e670c80971 Merge pull request #439 from carpedm20/graphql-cleanup
Clean up GraphQL helpers
2019-07-02 19:52:16 +02:00
Mads Marquart
ba7572eddd Merge branch 'master' into graphql-cleanup 2019-07-02 19:17:53 +02:00
Mads Marquart
a5c6fac976 Merge pull request #438 from carpedm20/explicit-error-handling
Add more explicit error handling and improve error handling in general
2019-07-02 18:57:47 +02:00
Mads Marquart
1293814c3a Remove GraphQL object in favor of helper functions 2019-07-02 18:26:35 +02:00
Mads Marquart
1b2aeb01ce Move GraphQL constants into the module 2019-07-02 18:23:29 +02:00
Mads Marquart
cab8abd1a0 Properly namespace GraphQL utility functions 2019-07-02 18:21:00 +02:00
Mads Marquart
edda2386fb Merge pull request #436 from carpedm20/clean-up-requests
Normalize a lot of request parsing
2019-07-02 18:08:19 +02:00
Mads Marquart
b0ad5f6097 Merge pull request #435 from carpedm20/state-refactor
Move state handling into new State model
2019-07-02 18:04:10 +02:00
Mads Marquart
6862bd7be3 Handle errors in payload explicitly 2019-07-02 17:52:10 +02:00
Mads Marquart
bc551a63c2 Improve GraphQL error handling 2019-07-02 17:50:33 +02:00
Mads Marquart
c9f11b924d Add more explicit error handling 2019-07-02 17:32:35 +02:00
Mads Marquart
3236ea5b97 Improve state refresh handler 2019-07-02 17:23:42 +02:00
Mads Marquart
794696d327 Improve payload error handling 2019-07-02 17:23:42 +02:00
Mads Marquart
7345de149a Improve HTTP error handling 2019-07-02 17:23:42 +02:00
Mads Marquart
4fdf0bbc57 Remove JSON conversion from _util.check_request 2019-07-02 17:23:42 +02:00
Mads Marquart
d17f741f97 Refactor _util.check_json 2019-07-02 17:23:42 +02:00
Mads Marquart
4a898b3ff5 Use Client._payload_post helper where relevant 2019-07-02 15:52:02 +02:00
Mads Marquart
7f84ca8d0c Add Client._payload_post helper 2019-07-02 15:50:58 +02:00
Mads Marquart
c3a974a495 Refactor _util.check_request 2019-07-02 15:34:23 +02:00
Mads Marquart
5b57d49a3e Remove Client._postFile 2019-07-02 15:14:02 +02:00
Mads Marquart
7af83c04c0 Remove as_json parameter
The requests that didn't need this parameter were moved to the State model
2019-07-01 22:53:26 +02:00
Mads Marquart
b5ba338f86 Remove fix_request parameter
The requests that don't need this parameter is handled in the State model
2019-07-01 22:49:21 +02:00
Mads Marquart
50bfeb92b2 Add fix_request=True and as_json=True to missing requests
I've tested, these endpoints actually all return JSON data
2019-07-01 22:47:05 +02:00
Mads Marquart
8d41ea5bfd Use POST in Client.fetchImageUrl
Reduces the amount of different request methods we're using.

Not really sure whether this is actually the best option:
- Each request includes `fb_dtsg` and such, so using POST everywhere might be the more secure option?
- But at the same time, the request is more opaque, and harder to debug (urllib3 logs all request urls automatically, so using GET would make that easy)
2019-07-01 18:43:00 +02:00
Mads Marquart
b10b14c8e9 Update url in Client.removeFriend 2019-07-01 18:23:03 +02:00
Mads Marquart
144e81bd46 Add Python 2 support 2019-07-01 13:40:15 +02:00
Mads Marquart
230c849b60 Always create the State object in a valid state 2019-07-01 13:31:42 +02:00
Mads Marquart
466f27a8c5 Move login check code into State 2019-07-01 13:31:42 +02:00
Mads Marquart
dc12e01fc7 Move logout code to State 2019-07-01 13:31:42 +02:00
Mads Marquart
d0e9a7f693 Move login/2fa code to State 2019-07-01 13:31:42 +02:00
Mads Marquart
1ba21e03c6 Handle headers in State 2019-07-01 13:31:42 +02:00
Mads Marquart
bcc8b44bb5 Handle ssl verification in State 2019-07-01 13:31:42 +02:00
Mads Marquart
b01b371c66 Refactor session cookie handling into State 2019-07-01 13:31:42 +02:00
Mads Marquart
94a0f6b3df Move client session into State 2019-07-01 13:31:42 +02:00
Mads Marquart
5df10ecc31 Remove _cleanGet and _cleanPost Client methods 2019-07-01 13:31:42 +02:00
Mads Marquart
56786406ec Refactor most of _postLogin into the State model 2019-07-01 13:31:42 +02:00
Mads Marquart
a4268f36cf Move logout h into the State model 2019-07-01 13:31:42 +02:00
Mads Marquart
8e7afa2edf Move request counter into State model 2019-07-01 13:31:30 +02:00
Mads Marquart
f07122d446 Move request payload into State model 2019-07-01 13:30:29 +02:00
Mads Marquart
78c307780b Clean up a few utility functions 2019-06-29 20:40:11 +02:00
Mads Marquart
ad705d544a Merge pull request #433 from carpedm20/remove-req-url-model
Remove ReqUrl model
2019-06-29 20:24:20 +02:00
Mads Marquart
77f28315c9 Inline urls from ReqUrl 2019-06-29 20:14:49 +02:00
Mads Marquart
e0754031ad Extract pull channel handling from ReqUrl 2019-06-29 20:10:55 +02:00
Mads Marquart
f97d36b41f Add ability to specify urls relative to www.facebook.com 2019-06-29 20:05:16 +02:00
Mads Marquart
bb2afe8e40 Remove redundant timeout parameter 2019-06-23 18:25:30 +02:00
Mads Marquart
faa0383af3 Remove unnecessary default payload attributes
This has been fairly thoroughly tested on all URLs, so it should be safe to do
2019-06-23 18:08:25 +02:00
47 changed files with 2889 additions and 2370 deletions

7
.bumpversion.cfg Normal file
View File

@@ -0,0 +1,7 @@
[bumpversion]
current_version = 1.9.1
commit = True
tag = True
[bumpversion:file:fbchat/__init__.py]

View File

@@ -21,8 +21,8 @@ Traceback (most recent call last):
File "[site-packages]/fbchat/client.py", line 78, in __init__ File "[site-packages]/fbchat/client.py", line 78, in __init__
self.login(email, password, max_tries) self.login(email, password, max_tries)
File "[site-packages]/fbchat/client.py", line 407, in login File "[site-packages]/fbchat/client.py", line 407, in login
raise FBchatUserError('Login failed. Check email/password. (Failed on url: {})'.format(login_url)) raise FBchatUserError('Login failed. Check email/password. (Failed on URL: {})'.format(login_url))
fbchat.models.FBchatUserError: Login failed. Check email/password. (Failed on url: https://m.facebook.com/login.php?login_attempt=1) fbchat.models.FBchatUserError: Login failed. Check email/password. (Failed on URL: https://m.facebook.com/login.php?login_attempt=1)
``` ```
## Environment information ## Environment information

1
.gitignore vendored
View File

@@ -35,3 +35,4 @@ tests.data
# Virtual environment # Virtual environment
venv/ venv/
.venv*/

18
.readthedocs.yml Normal file
View File

@@ -0,0 +1,18 @@
# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
version: 2
formats:
- pdf
- htmlzip
python:
version: 3.6
install:
- path: .
extra_requirements:
- docs
# Build documentation in the docs/ directory with Sphinx
sphinx:
configuration: docs/conf.py
fail_on_warning: true

View File

@@ -30,7 +30,7 @@ jobs:
script: black --check --verbose . script: black --check --verbose .
- stage: deploy - stage: deploy
name: Github Releases name: GitHub Releases
if: tag IS present if: tag IS present
install: skip install: skip
script: flit build script: flit build

View File

@@ -1,5 +1,5 @@
Contributing to fbchat Contributing to ``fbchat``
====================== ==========================
Thanks for reading this, all contributions are very much welcome! Thanks for reading this, all contributions are very much welcome!

View File

@@ -1,5 +1,5 @@
fbchat: Facebook Chat (Messenger) for Python ``fbchat``: Facebook Chat (Messenger) for Python
============================================ ================================================
.. image:: https://img.shields.io/badge/license-BSD-blue.svg .. image:: https://img.shields.io/badge/license-BSD-blue.svg
:target: https://github.com/carpedm20/fbchat/tree/master/LICENSE :target: https://github.com/carpedm20/fbchat/tree/master/LICENSE
@@ -9,7 +9,7 @@ fbchat: Facebook Chat (Messenger) for Python
:target: https://pypi.python.org/pypi/fbchat :target: https://pypi.python.org/pypi/fbchat
:alt: Supported python versions: 2.7, 3.4, 3.5, 3.6, 3.7 and pypy :alt: Supported python versions: 2.7, 3.4, 3.5, 3.6, 3.7 and pypy
.. image:: https://readthedocs.org/projects/fbchat/badge/?version=master .. image:: https://readthedocs.org/projects/fbchat/badge/?version=latest
:target: https://fbchat.readthedocs.io :target: https://fbchat.readthedocs.io
:alt: Documentation :alt: Documentation
@@ -35,14 +35,12 @@ Installation:
$ pip install fbchat $ pip install fbchat
You can also install from source, by using `flit`: You can also install from source if you have ``pip>=19.0``:
.. code-block:: .. code-block::
$ pip install flit
$ git clone https://github.com/carpedm20/fbchat.git $ git clone https://github.com/carpedm20/fbchat.git
$ cd fbchat $ pip install fbchat
$ flit install
Maintainer Maintainer

View File

@@ -3,8 +3,7 @@
# You can set these variables from the command line. # You can set these variables from the command line.
SPHINXOPTS = SPHINXOPTS =
SPHINXBUILD = python3.6 -msphinx SPHINXBUILD = sphinx-build
SPHINXPROJ = fbchat
SOURCEDIR = . SOURCEDIR = .
BUILDDIR = _build BUILDDIR = _build

View File

@@ -1,33 +1,79 @@
.. module:: fbchat .. module:: fbchat
.. highlight:: python
.. _api: .. _api:
.. Note: we're using () to hide the __init__ method where relevant
Full API Full API
======== ========
If you are looking for information on a specific function, class, or method, this part of the documentation is for you. If you are looking for information on a specific function, class, or method, this part of the documentation is for you.
.. _api_client:
Client Client
------ ------
This is the main class of `fbchat`, which contains all the methods you use to interact with Facebook. .. autoclass:: Client
You can extend this class, and overwrite the events, to provide custom event handling (mainly used while listening)
.. autoclass:: Client(email, password, user_agent=None, max_tries=5, session_cookies=None, logging_level=logging.INFO) Threads
:members: -------
.. autoclass:: Thread()
.. _api_models: .. autoclass:: ThreadType(Enum)
:undoc-members:
Models .. autoclass:: Page()
------ .. autoclass:: User()
.. autoclass:: Group()
These models are used in various functions, both as inputs and return values.
A good tip is to write ``from fbchat.models import *`` at the start of your source, so you can use these models freely Messages
--------
.. automodule:: fbchat.models
:members: .. autoclass:: Message
.. autoclass:: Mention
.. autoclass:: EmojiSize(Enum)
:undoc-members:
.. autoclass:: MessageReaction(Enum)
:undoc-members:
Exceptions
----------
.. autoexception:: FBchatException()
.. autoexception:: FBchatFacebookError()
.. autoexception:: FBchatUserError()
Attachments
-----------
.. autoclass:: Attachment()
.. autoclass:: ShareAttachment()
.. autoclass:: Sticker()
.. autoclass:: LocationAttachment()
.. autoclass:: LiveLocationAttachment()
.. autoclass:: FileAttachment()
.. autoclass:: AudioAttachment()
.. autoclass:: ImageAttachment()
.. autoclass:: VideoAttachment()
.. autoclass:: ImageAttachment()
Miscellaneous
-------------
.. autoclass:: ThreadLocation(Enum)
:undoc-members:
.. autoclass:: ThreadColor(Enum)
:undoc-members:
.. autoclass:: ActiveStatus()
.. autoclass:: TypingStatus(Enum)
:undoc-members:
.. autoclass:: QuickReply
.. autoclass:: QuickReplyText
.. autoclass:: QuickReplyLocation
.. autoclass:: QuickReplyPhoneNumber
.. autoclass:: QuickReplyEmail
.. autoclass:: Poll
.. autoclass:: PollOption
.. autoclass:: Plan
.. autoclass:: GuestStatus(Enum)
:undoc-members: :undoc-members:

View File

@@ -1,21 +1,12 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# #
# fbchat documentation build configuration file, created by # Configuration file for the Sphinx documentation builder.
# sphinx-quickstart on Thu May 25 15:43:01 2017.
# #
# This file is execfile()d with the current directory set to its # This file does only contain a selection of the most common options. For a
# containing dir. # full list see the documentation:
# # http://www.sphinx-doc.org/en/master/config
# Note that not all possible configuration values are present in this
# autogenerated file.
#
# All configuration values have a default; values that are commented out
# serve to show the default.
# If extensions (or modules to document with autodoc) are in another directory, # -- Path setup --------------------------------------------------------------
# add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here.
import os import os
import sys import sys
@@ -23,15 +14,24 @@ import sys
sys.path.insert(0, os.path.abspath("..")) sys.path.insert(0, os.path.abspath(".."))
import fbchat import fbchat
import tests
from fbchat import __copyright__, __author__, __version__, __description__ # -- Project information -----------------------------------------------------
project = fbchat.__name__
copyright = fbchat.__copyright__
author = fbchat.__author__
# The short X.Y version
version = fbchat.__version__
# The full version, including alpha/beta/rc tags
release = fbchat.__version__
# -- General configuration ------------------------------------------------ # -- General configuration ---------------------------------------------------
# If your documentation needs a minimal Sphinx version, state it here. # If your documentation needs a minimal Sphinx version, state it here.
# #
# needs_sphinx = '1.0' needs_sphinx = "2.0"
# Add any Sphinx extension module names here, as strings. They can be # Add any Sphinx extension module names here, as strings. They can be
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
@@ -41,127 +41,52 @@ extensions = [
"sphinx.ext.intersphinx", "sphinx.ext.intersphinx",
"sphinx.ext.todo", "sphinx.ext.todo",
"sphinx.ext.viewcode", "sphinx.ext.viewcode",
"sphinx.ext.napoleon",
"sphinxcontrib.spelling",
] ]
# Add any paths that contain templates here, relative to this directory. # Add any paths that contain templates here, relative to this directory.
templates_path = ["_templates"] templates_path = ["_templates"]
# The suffix(es) of source filenames.
# You can specify multiple suffix as a list of string:
#
# source_suffix = ['.rst', '.md']
source_suffix = ".rst"
# The master toctree document. # The master toctree document.
master_doc = "index" master_doc = "index"
# General information about the project.
project = "fbchat"
title = "fbchat Documentation"
copyright = __copyright__
author = __author__
description = __description__
# The version info for the project you're documenting, acts as replacement for
# |version| and |release|, also used in various other places throughout the
# built documents.
#
# The short X.Y version.
version = __version__
# The full version, including alpha/beta/rc tags.
release = __version__
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
#
# This is also used if you do content translation via gettext catalogs.
# Usually you set "language" from the command line for these cases.
language = None
# List of patterns, relative to source directory, that match files and # List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files. # directories to ignore when looking for source files.
# This patterns also effect to html_static_path and html_extra_path # This pattern also affects html_static_path and html_extra_path.
exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
# The name of the Pygments (syntax highlighting) style to use. rst_prolog = ".. currentmodule:: " + project
pygments_style = "sphinx"
# If true, `todo` and `todoList` produce output, else they produce nothing. # The reST default role (used for this markup: `text`) to use for all
todo_include_todos = True # documents.
#
default_role = "any"
# Make the reference parsing more strict
#
nitpicky = True
# Prefer strict Python highlighting
#
highlight_language = "python3"
# If true, '()' will be appended to :func: etc. cross-reference text.
#
add_function_parentheses = False
# -- Options for HTML output ---------------------------------------------- # -- Options for HTML output -------------------------------------------------
# The theme to use for HTML and HTML Help pages. See the documentation for # The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes. # a list of builtin themes.
# #
html_theme = "alabaster" html_theme = "alabaster"
# Theme options are theme-specific and customize the look and feel of a theme # Theme options are theme-specific and customize the look and feel of a theme
# further. For a list of options available for each theme, see the # further. For a list of options available for each theme, see the
# documentation. # documentation.
# #
# html_theme_options = {}
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = ["_static"]
# -- Options for HTMLHelp output ------------------------------------------
# Output file base name for HTML help builder.
htmlhelp_basename = project + "doc"
# -- Options for LaTeX output ---------------------------------------------
latex_elements = {
# The paper size ('letterpaper' or 'a4paper').
#
# 'papersize': 'letterpaper',
# The font size ('10pt', '11pt' or '12pt').
#
# 'pointsize': '10pt',
# Additional stuff for the LaTeX preamble.
#
# 'preamble': '',
# Latex figure (float) alignment
#
# 'figure_align': 'htbp',
}
# Grouping the document tree into LaTeX files. List of tuples
# (source start file, target name, title,
# author, documentclass [howto, manual, or own class]).
latex_documents = [(master_doc, project + ".tex", title, author, "manual")]
# -- Options for manual page output ---------------------------------------
# One entry per manual page. List of tuples
# (source start file, name, description, authors, manual section).
man_pages = [(master_doc, project, title, [author], 1)]
# -- Options for Texinfo output -------------------------------------------
# Grouping the document tree into Texinfo files. List of tuples
# (source start file, target name, title, author,
# dir menu entry, description, category)
texinfo_documents = [
(master_doc, project, title, author, project, description, "Miscellaneous")
]
# Example configuration for intersphinx: refer to the Python standard library.
intersphinx_mapping = {"https://docs.python.org/3/": None}
add_function_parentheses = False
html_theme_options = { html_theme_options = {
"show_powered_by": False, "show_powered_by": False,
"github_user": "carpedm20", "github_user": "carpedm20",
@@ -170,9 +95,114 @@ html_theme_options = {
"show_related": False, "show_related": False,
} }
# Custom sidebar templates, must be a dictionary that maps document names
# to template names.
#
# The default sidebars (for documents that don't match any pattern) are
# defined by theme itself. Builtin themes are using these templates by
# default: ``['localtoc.html', 'relations.html', 'sourcelink.html',
# 'searchbox.html']``.
#
html_sidebars = {"**": ["sidebar.html", "searchbox.html"]} html_sidebars = {"**": ["sidebar.html", "searchbox.html"]}
# If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
#
html_show_sphinx = False html_show_sphinx = False
# If true, links to the reST sources are added to the pages.
#
html_show_sourcelink = False html_show_sourcelink = False
# A shorter title for the navigation bar. Default is the same as html_title.
#
html_short_title = fbchat.__description__
# -- Options for HTMLHelp output ---------------------------------------------
# Output file base name for HTML help builder.
htmlhelp_basename = project + "doc"
# -- Options for LaTeX output ------------------------------------------------
# Grouping the document tree into LaTeX files. List of tuples
# (source start file, target name, title,
# author, documentclass [howto, manual, or own class]).
latex_documents = [(master_doc, project + ".tex", fbchat.__title__, author, "manual")]
# -- Options for manual page output ------------------------------------------
# One entry per manual page. List of tuples
# (source start file, name, description, authors, manual section).
man_pages = [
(master_doc, project, fbchat.__title__, [x.strip() for x in author.split(";")], 1)
]
# -- Options for Texinfo output ----------------------------------------------
# Grouping the document tree into Texinfo files. List of tuples
# (source start file, target name, title, author,
# dir menu entry, description, category)
texinfo_documents = [
(
master_doc,
project,
fbchat.__title__,
author,
project,
fbchat.__description__,
"Miscellaneous",
)
]
# -- Options for Epub output -------------------------------------------------
# A list of files that should not be packed into the epub file.
epub_exclude_files = ["search.html"]
# -- Extension configuration -------------------------------------------------
# -- Options for autodoc extension ---------------------------------------
autoclass_content = "both" autoclass_content = "both"
html_short_title = description autodoc_member_order = "bysource"
autodoc_default_options = {"members": True}
# -- Options for intersphinx extension ---------------------------------------
# Example configuration for intersphinx: refer to the Python standard library.
intersphinx_mapping = {"https://docs.python.org/": None}
# -- Options for todo extension ----------------------------------------------
# If true, `todo` and `todoList` produce output, else they produce nothing.
todo_include_todos = True
todo_link_only = True
# -- Options for napoleon extension ----------------------------------------------
# Use Google style docstrings
napoleon_google_docstring = True
napoleon_numpy_docstring = False
# napoleon_use_admonition_for_examples = False
# napoleon_use_admonition_for_notes = False
# napoleon_use_admonition_for_references = False
# -- Options for spelling extension ----------------------------------------------
spelling_word_list_filename = [
"spelling/names.txt",
"spelling/technical.txt",
"spelling/fixes.txt",
]
spelling_ignore_wiki_words = False
# spelling_ignore_acronyms = False
spelling_ignore_python_builtins = False
spelling_ignore_importable_modules = False

View File

@@ -1,16 +1,15 @@
.. highlight:: python
.. _examples: .. _examples:
Examples Examples
======== ========
These are a few examples on how to use `fbchat`. Remember to swap out `<email>` and `<password>` for your email and password These are a few examples on how to use ``fbchat``. Remember to swap out ``<email>`` and ``<password>`` for your email and password
Basic example Basic example
------------- -------------
This will show basic usage of `fbchat` This will show basic usage of ``fbchat``
.. literalinclude:: ../examples/basic_usage.py .. literalinclude:: ../examples/basic_usage.py
@@ -18,7 +17,7 @@ This will show basic usage of `fbchat`
Interacting with Threads Interacting with Threads
------------------------ ------------------------
This will interact with the thread in every way `fbchat` supports This will interact with the thread in every way ``fbchat`` supports
.. literalinclude:: ../examples/interract.py .. literalinclude:: ../examples/interract.py
@@ -31,8 +30,8 @@ This will show the different ways of fetching information about users and thread
.. literalinclude:: ../examples/fetch.py .. literalinclude:: ../examples/fetch.py
Echobot ``Echobot``
------- -----------
This will reply to any message with the same message This will reply to any message with the same message
@@ -42,7 +41,7 @@ This will reply to any message with the same message
Remove Bot Remove Bot
---------- ----------
This will remove a user from a group if they write the message `Remove me!` This will remove a user from a group if they write the message ``Remove me!``
.. literalinclude:: ../examples/removebot.py .. literalinclude:: ../examples/removebot.py

View File

@@ -1,5 +1,3 @@
.. highlight:: python
.. module:: fbchat
.. _faq: .. _faq:
FAQ FAQ
@@ -11,7 +9,7 @@ Version X broke my installation
We try to provide backwards compatibility where possible, but since we're not part of Facebook, We try to provide backwards compatibility where possible, but since we're not part of Facebook,
most of the things may be broken at any point in time most of the things may be broken at any point in time
Downgrade to an earlier version of fbchat, run this command Downgrade to an earlier version of ``fbchat``, run this command
.. code-block:: sh .. code-block:: sh
@@ -23,14 +21,14 @@ Where you replace ``<X>`` with the version you want to use
Will you be supporting creating posts/events/pages and so on? Will you be supporting creating posts/events/pages and so on?
------------------------------------------------------------- -------------------------------------------------------------
We won't be focusing on anything else than chat-related things. This API is called `fbCHAT`, after all ;) We won't be focusing on anything else than chat-related things. This API is called ``fbCHAT``, after all ;)
Submitting Issues Submitting Issues
----------------- -----------------
If you're having trouble with some of the snippets, or you think some of the functionality is broken, If you're having trouble with some of the snippets, or you think some of the functionality is broken,
please feel free to submit an issue on `Github <https://github.com/carpedm20/fbchat>`_. please feel free to submit an issue on `GitHub <https://github.com/carpedm20/fbchat>`_.
You should first login with ``logging_level`` set to ``logging.DEBUG``:: You should first login with ``logging_level`` set to ``logging.DEBUG``::
from fbchat import Client from fbchat import Client

View File

@@ -1,5 +1,3 @@
.. highlight:: python
.. module:: fbchat
.. fbchat documentation master file, created by .. fbchat documentation master file, created by
sphinx-quickstart on Thu May 25 15:43:01 2017. sphinx-quickstart on Thu May 25 15:43:01 2017.
You can adapt this file completely to your liking, but it should at least You can adapt this file completely to your liking, but it should at least
@@ -8,8 +6,8 @@
.. This documentation's layout is heavily inspired by requests' layout: https://requests.readthedocs.io .. This documentation's layout is heavily inspired by requests' layout: https://requests.readthedocs.io
Some documentation is also partially copied from facebook-chat-api: https://github.com/Schmavery/facebook-chat-api Some documentation is also partially copied from facebook-chat-api: https://github.com/Schmavery/facebook-chat-api
fbchat: Facebook Chat (Messenger) for Python ``fbchat``: Facebook Chat (Messenger) for Python
============================================ ================================================
Release v\ |version|. (:ref:`install`) Release v\ |version|. (:ref:`install`)
@@ -30,14 +28,14 @@ This project was inspired by `facebook-chat-api <https://github.com/Schmavery/fa
**No XMPP or API key is needed**. Just use your email and password. **No XMPP or API key is needed**. Just use your email and password.
Currently `fbchat` support Python 2.7, 3.4, 3.5 and 3.6: Currently ``fbchat`` support Python 2.7, 3.4, 3.5 and 3.6:
`fbchat` works by emulating the browser. ``fbchat`` works by emulating the browser.
This means doing the exact same GET/POST requests and tricking Facebook into thinking it's accessing the website normally. This means doing the exact same GET/POST requests and tricking Facebook into thinking it's accessing the website normally.
Therefore, this API requires the credentials of a Facebook account. Therefore, this API requires the credentials of a Facebook account.
.. note:: .. note::
If you're having problems, please check the :ref:`faq`, before asking questions on Github If you're having problems, please check the :ref:`faq`, before asking questions on GitHub
.. warning:: .. warning::
We are not responsible if your account gets banned for spammy activities, We are not responsible if your account gets banned for spammy activities,
@@ -46,9 +44,9 @@ Therefore, this API requires the credentials of a Facebook account.
.. note:: .. note::
Facebook now has an `official API <https://developers.facebook.com/docs/messenger-platform>`_ for chat bots, Facebook now has an `official API <https://developers.facebook.com/docs/messenger-platform>`_ for chat bots,
so if you're familiar with node.js, this might be what you're looking for. so if you're familiar with ``Node.js``, this might be what you're looking for.
If you're already familiar with the basics of how Facebook works internally, go to :ref:`examples` to see example usage of `fbchat` If you're already familiar with the basics of how Facebook works internally, go to :ref:`examples` to see example usage of ``fbchat``
Overview Overview

View File

@@ -1,13 +1,14 @@
.. highlight:: sh
.. _install: .. _install:
Installation Installation
============ ============
Pip Install fbchat Install using pip
------------------ -----------------
To install fbchat, run this command:: To install ``fbchat``, run this command:
.. code-block:: sh
$ pip install fbchat $ pip install fbchat
@@ -18,19 +19,25 @@ can guide you through the process.
Get the Source Code Get the Source Code
------------------- -------------------
fbchat is developed on GitHub, where the code is ``fbchat`` is developed on GitHub, where the code is
`always available <https://github.com/carpedm20/fbchat>`_. `always available <https://github.com/carpedm20/fbchat>`_.
You can either clone the public repository:: You can either clone the public repository:
.. code-block:: sh
$ git clone git://github.com/carpedm20/fbchat.git $ git clone git://github.com/carpedm20/fbchat.git
Or, download a `tarball <https://github.com/carpedm20/fbchat/tarball/master>`_:: Or, download a `tarball <https://github.com/carpedm20/fbchat/tarball/master>`_:
.. code-block:: sh
$ curl -OL https://github.com/carpedm20/fbchat/tarball/master $ curl -OL https://github.com/carpedm20/fbchat/tarball/master
# optionally, zipball is also available (for Windows users). # optionally, zipball is also available (for Windows users).
Once you have a copy of the source, you can embed it in your own Python Once you have a copy of the source, you can embed it in your own Python
package, or install it into your site-packages easily:: package, or install it into your site-packages easily:
.. code-block:: sh
$ python setup.py install $ python setup.py install

View File

@@ -1,11 +1,9 @@
.. highlight:: python
.. module:: fbchat
.. _intro: .. _intro:
Introduction Introduction
============ ============
`fbchat` uses your email and password to communicate with the Facebook server. ``fbchat`` uses your email and password to communicate with the Facebook server.
That means that you should always store your password in a separate file, in case e.g. someone looks over your shoulder while you're writing code. That means that you should always store your password in a separate file, in case e.g. someone looks over your shoulder while you're writing code.
You should also make sure that the file's access control is appropriately restrictive You should also make sure that the file's access control is appropriately restrictive
@@ -26,9 +24,9 @@ Replace ``<email>`` and ``<password>`` with your email and password respectively
.. note:: .. note::
For ease of use then most of the code snippets in this document will assume you've already completed the login process For ease of use then most of the code snippets in this document will assume you've already completed the login process
Though the second line, ``from fbchat.models import *``, is not strictly neccesary here, later code snippets will assume you've done this Though the second line, ``from fbchat.models import *``, is not strictly necessary here, later code snippets will assume you've done this
If you want to change how verbose `fbchat` is, change the logging level (in :class:`Client`) If you want to change how verbose ``fbchat`` is, change the logging level (in :class:`Client`)
Throughout your code, if you want to check whether you are still logged in, use :func:`Client.isLoggedIn`. Throughout your code, if you want to check whether you are still logged in, use :func:`Client.isLoggedIn`.
An example would be to login again if you've been logged out, using :func:`Client.login`:: An example would be to login again if you've been logged out, using :func:`Client.login`::
@@ -48,9 +46,9 @@ Threads
A thread can refer to two things: A Messenger group chat or a single Facebook user A thread can refer to two things: A Messenger group chat or a single Facebook user
:class:`models.ThreadType` is an enumerator with two values: ``USER`` and ``GROUP``. :class:`ThreadType` is an enumerator with two values: ``USER`` and ``GROUP``.
These will specify whether the thread is a single user chat or a group chat. These will specify whether the thread is a single user chat or a group chat.
This is required for many of `fbchat`'s functions, since Facebook differentiates between these two internally This is required for many of ``fbchat``'s functions, since Facebook differentiates between these two internally
Searching for group chats and finding their ID can be done via. :func:`Client.searchForGroups`, Searching for group chats and finding their ID can be done via. :func:`Client.searchForGroups`,
and searching for users is possible via. :func:`Client.searchForUsers`. See :ref:`intro_fetching` and searching for users is possible via. :func:`Client.searchForUsers`. See :ref:`intro_fetching`
@@ -87,7 +85,7 @@ Message IDs
Every message you send on Facebook has a unique ID, and every action you do in a thread, Every message you send on Facebook has a unique ID, and every action you do in a thread,
like changing a nickname or adding a person, has a unique ID too. like changing a nickname or adding a person, has a unique ID too.
Some of `fbchat`'s functions require these ID's, like :func:`Client.reactToMessage`, Some of ``fbchat``'s functions require these ID's, like :func:`Client.reactToMessage`,
and some of then provide this ID, like :func:`Client.sendMessage`. and some of then provide this ID, like :func:`Client.sendMessage`.
This snippet shows how to send a message, and then use the returned ID to react to that message with a 😍 emoji:: This snippet shows how to send a message, and then use the returned ID to react to that message with a 😍 emoji::
@@ -100,17 +98,17 @@ This snippet shows how to send a message, and then use the returned ID to react
Interacting with Threads Interacting with Threads
------------------------ ------------------------
`fbchat` provides multiple functions for interacting with threads ``fbchat`` provides multiple functions for interacting with threads
Most functionality works on all threads, though some things, Most functionality works on all threads, though some things,
like adding users to and removing users from a group chat, logically only works on group chats like adding users to and removing users from a group chat, logically only works on group chats
The simplest way of using `fbchat` is to send a message. The simplest way of using ``fbchat`` is to send a message.
The following snippet will, as you've probably already figured out, send the message `test message` to your account:: The following snippet will, as you've probably already figured out, send the message ``test message`` to your account::
message_id = client.send(Message(text='test message'), thread_id=client.uid, thread_type=ThreadType.USER) message_id = client.send(Message(text='test message'), thread_id=client.uid, thread_type=ThreadType.USER)
You can see a full example showing all the possible thread interactions with `fbchat` by going to :ref:`examples` You can see a full example showing all the possible thread interactions with ``fbchat`` by going to :ref:`examples`
.. _intro_fetching: .. _intro_fetching:
@@ -118,7 +116,7 @@ You can see a full example showing all the possible thread interactions with `fb
Fetching Information Fetching Information
-------------------- --------------------
You can use `fbchat` to fetch basic information like user names, profile pictures, thread names and user IDs You can use ``fbchat`` to fetch basic information like user names, profile pictures, thread names and user IDs
You can retrieve a user's ID with :func:`Client.searchForUsers`. You can retrieve a user's ID with :func:`Client.searchForUsers`.
The following snippet will search for users by their name, take the first (and most likely) user, and then get their user ID from the result:: The following snippet will search for users by their name, take the first (and most likely) user, and then get their user ID from the result::
@@ -127,12 +125,12 @@ The following snippet will search for users by their name, take the first (and m
user = users[0] user = users[0]
print("User's ID: {}".format(user.uid)) print("User's ID: {}".format(user.uid))
print("User's name: {}".format(user.name)) print("User's name: {}".format(user.name))
print("User's profile picture url: {}".format(user.photo)) print("User's profile picture URL: {}".format(user.photo))
print("User's main url: {}".format(user.url)) print("User's main URL: {}".format(user.url))
Since this uses Facebook's search functions, you don't have to specify the whole name, first names will usually be enough Since this uses Facebook's search functions, you don't have to specify the whole name, first names will usually be enough
You can see a full example showing all the possible ways to fetch information with `fbchat` by going to :ref:`examples` You can see a full example showing all the possible ways to fetch information with ``fbchat`` by going to :ref:`examples`
.. _intro_sessions: .. _intro_sessions:
@@ -140,7 +138,7 @@ You can see a full example showing all the possible ways to fetch information wi
Sessions Sessions
-------- --------
`fbchat` provides functions to retrieve and set the session cookies. ``fbchat`` provides functions to retrieve and set the session cookies.
This will enable you to store the session cookies in a separate file, so that you don't have to login each time you start your script. This will enable you to store the session cookies in a separate file, so that you don't have to login each time you start your script.
Use :func:`Client.getSession` to retrieve the cookies:: Use :func:`Client.getSession` to retrieve the cookies::
@@ -156,7 +154,7 @@ Or you can set the ``session_cookies`` on your initial login.
client = Client('<email>', '<password>', session_cookies=session_cookies) client = Client('<email>', '<password>', session_cookies=session_cookies)
.. warning:: .. warning::
You session cookies can be just as valueable as you password, so store them with equal care You session cookies can be just as valuable as you password, so store them with equal care
.. _intro_events: .. _intro_events:
@@ -164,13 +162,13 @@ Or you can set the ``session_cookies`` on your initial login.
Listening & Events Listening & Events
------------------ ------------------
To use the listening functions `fbchat` offers (like :func:`Client.listen`), To use the listening functions ``fbchat`` offers (like :func:`Client.listen`),
you have to define what should be executed when certain events happen. you have to define what should be executed when certain events happen.
By default, (most) events will just be a `logging.info` statement, By default, (most) events will just be a `logging.info` statement,
meaning it will simply print information to the console when an event happens meaning it will simply print information to the console when an event happens
.. note:: .. note::
You can identify the event methods by their `on` prefix, e.g. `onMessage` You can identify the event methods by their ``on`` prefix, e.g. `onMessage`
The event actions can be changed by subclassing the :class:`Client`, and then overwriting the event methods:: The event actions can be changed by subclassing the :class:`Client`, and then overwriting the event methods::
@@ -194,7 +192,7 @@ The change was in the parameters that our `onMessage` method took: ``message_obj
and ``mid``, ``ts``, ``metadata`` and ``msg`` got removed, but the function still works, since we included ``**kwargs`` and ``mid``, ``ts``, ``metadata`` and ``msg`` got removed, but the function still works, since we included ``**kwargs``
.. note:: .. note::
Therefore, for both backwards and forwards compatability, Therefore, for both backwards and forwards compatibility,
the API actually requires that you include ``**kwargs`` as your final argument. the API actually requires that you include ``**kwargs`` as your final argument.
View the :ref:`examples` to see some more examples illustrating the event system View the :ref:`examples` to see some more examples illustrating the event system

View File

@@ -5,21 +5,20 @@ pushd %~dp0
REM Command file for Sphinx documentation REM Command file for Sphinx documentation
if "%SPHINXBUILD%" == "" ( if "%SPHINXBUILD%" == "" (
set SPHINXBUILD=python -msphinx set SPHINXBUILD=sphinx-build
) )
set SOURCEDIR=. set SOURCEDIR=.
set BUILDDIR=_build set BUILDDIR=_build
set SPHINXPROJ=fbchat
if "%1" == "" goto help if "%1" == "" goto help
%SPHINXBUILD% >NUL 2>NUL %SPHINXBUILD% >NUL 2>NUL
if errorlevel 9009 ( if errorlevel 9009 (
echo. echo.
echo.The Sphinx module was not found. Make sure you have Sphinx installed, echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
echo.then set the SPHINXBUILD environment variable to point to the full echo.installed, then set the SPHINXBUILD environment variable to point
echo.path of the 'sphinx-build' executable. Alternatively you may add the echo.to the full path of the 'sphinx-build' executable. Alternatively you
echo.Sphinx directory to PATH. echo.may add the Sphinx directory to PATH.
echo. echo.
echo.If you don't have Sphinx installed, grab it from echo.If you don't have Sphinx installed, grab it from
echo.http://sphinx-doc.org/ echo.http://sphinx-doc.org/

3
docs/spelling/fixes.txt Normal file
View File

@@ -0,0 +1,3 @@
premade
todo
emoji

3
docs/spelling/names.txt Normal file
View File

@@ -0,0 +1,3 @@
Facebook
GraphQL
GitHub

View File

@@ -0,0 +1,14 @@
iterables
timestamp
metadata
spam
spammy
admin
admins
unsend
unsends
unmute
spritemap
online
inbox
subclassing

View File

@@ -1,5 +1,3 @@
.. highlight:: sh
.. module:: fbchat
.. _testing: .. _testing:
Testing Testing
@@ -15,7 +13,9 @@ To use the tests, copy ``tests/data.json`` to ``tests/my_data.json`` or type the
Please remember to test all supported python versions. Please remember to test all supported python versions.
If you've made any changes to the 2FA functionality, test it with a 2FA enabled account. If you've made any changes to the 2FA functionality, test it with a 2FA enabled account.
If you only want to execute specific tests, pass the function names in the command line (not including the `test_` prefix). Example:: If you only want to execute specific tests, pass the function names in the command line (not including the ``test_`` prefix). Example:
.. code-block:: sh
$ python tests.py sendMessage sessions sendEmoji $ python tests.py sendMessage sessions sendEmoji
@@ -23,7 +23,3 @@ If you only want to execute specific tests, pass the function names in the comma
Do not execute the full set of tests in too quick succession. This can get your account temporarily blocked for spam! Do not execute the full set of tests in too quick succession. This can get your account temporarily blocked for spam!
(You should execute the script at max about 10 times a day) (You should execute the script at max about 10 times a day)
.. automodule:: tests
:members: TestFbchat
:undoc-members: TestFbchat

View File

@@ -1,5 +1,3 @@
.. highlight:: python
.. module:: fbchat
.. _todo: .. _todo:
Todo Todo
@@ -11,11 +9,11 @@ This page will be periodically updated to show missing features and documentatio
Missing Functionality Missing Functionality
--------------------- ---------------------
- Implement Client.searchForMessage - Implement ``Client.searchForMessage``
- This will use the graphql request API - This will use the GraphQL request API
- Implement chatting with pages properly - Implement chatting with pages properly
- Write better FAQ - Write better FAQ
- Explain usage of graphql - Explain usage of GraphQL
Documentation Documentation

View File

@@ -1,5 +1,6 @@
# -*- coding: UTF-8 -*- # -*- coding: UTF-8 -*-
from itertools import islice
from fbchat import Client from fbchat import Client
from fbchat.models import * from fbchat.models import *
@@ -62,3 +63,9 @@ print("thread's type: {}".format(thread.type))
# Here should be an example of `getUnread` # Here should be an example of `getUnread`
# Print image url for 20 last images from thread.
images = client.fetchThreadImages("<thread id>")
for image in islice(image, 20):
print(image.large_preview_url)

View File

@@ -47,7 +47,7 @@ client.sendLocalImage(
thread_type=thread_type, thread_type=thread_type,
) )
# Will download the image at the url `<image url>`, and then send it # Will download the image at the URL `<image url>`, and then send it
client.sendRemoteImage( client.sendRemoteImage(
"<image url>", "<image url>",
message=Message(text="This is a remote image"), message=Message(text="This is a remote image"),

View File

@@ -13,7 +13,7 @@ from ._client import Client
from ._util import log # TODO: Remove this (from examples too) from ._util import log # TODO: Remove this (from examples too)
__title__ = "fbchat" __title__ = "fbchat"
__version__ = "1.7.0" __version__ = "1.9.1"
__description__ = "Facebook Chat (Messenger) for Python" __description__ = "Facebook Chat (Messenger) for Python"
__copyright__ = "Copyright 2015 - 2019 by Taehoon Kim" __copyright__ = "Copyright 2015 - 2019 by Taehoon Kim"

View File

@@ -7,7 +7,7 @@ from . import _util
@attr.s(cmp=False) @attr.s(cmp=False)
class Attachment(object): class Attachment(object):
"""Represents a Facebook attachment""" """Represents a Facebook attachment."""
#: The attachment ID #: The attachment ID
uid = attr.ib(None) uid = attr.ib(None)
@@ -15,12 +15,12 @@ class Attachment(object):
@attr.s(cmp=False) @attr.s(cmp=False)
class UnsentMessage(Attachment): class UnsentMessage(Attachment):
"""Represents an unsent message attachment""" """Represents an unsent message attachment."""
@attr.s(cmp=False) @attr.s(cmp=False)
class ShareAttachment(Attachment): class ShareAttachment(Attachment):
"""Represents a shared item (eg. URL) that has been sent as a Facebook attachment""" """Represents a shared item (e.g. URL) attachment."""
#: ID of the author of the shared post #: ID of the author of the shared post
author = attr.ib(None) author = attr.ib(None)
@@ -36,7 +36,7 @@ class ShareAttachment(Attachment):
source = attr.ib(None) source = attr.ib(None)
#: URL of the attachment image #: URL of the attachment image
image_url = attr.ib(None) image_url = attr.ib(None)
#: URL of the original image if Facebook uses `safe_image` #: URL of the original image if Facebook uses ``safe_image``
original_image_url = attr.ib(None) original_image_url = attr.ib(None)
#: Width of the image #: Width of the image
image_width = attr.ib(None) image_width = attr.ib(None)

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,7 @@ log = logging.getLogger("client")
class Enum(aenum.Enum): class Enum(aenum.Enum):
"""Used internally by fbchat to support enumerations""" """Used internally by ``fbchat`` to support enumerations"""
def __repr__(self): def __repr__(self):
# For documentation: # For documentation:

View File

@@ -3,7 +3,10 @@ from __future__ import unicode_literals
class FBchatException(Exception): class FBchatException(Exception):
"""Custom exception thrown by fbchat. All exceptions in the fbchat module inherits this""" """Custom exception thrown by ``fbchat``.
All exceptions in the ``fbchat`` module inherits this.
"""
class FBchatFacebookError(FBchatException): class FBchatFacebookError(FBchatException):
@@ -11,7 +14,7 @@ class FBchatFacebookError(FBchatException):
fb_error_code = None fb_error_code = None
#: The error message that Facebook returned (In the user's own language) #: The error message that Facebook returned (In the user's own language)
fb_error_message = None fb_error_message = None
#: The status code that was sent in the http response (eg. 404) (Usually only set if not successful, aka. not 200) #: The status code that was sent in the HTTP response (e.g. 404) (Usually only set if not successful, aka. not 200)
request_status_code = None request_status_code = None
def __init__( def __init__(
@@ -22,11 +25,36 @@ class FBchatFacebookError(FBchatException):
request_status_code=None, request_status_code=None,
): ):
super(FBchatFacebookError, self).__init__(message) super(FBchatFacebookError, self).__init__(message)
"""Thrown by fbchat when Facebook returns an error""" """Thrown by ``fbchat`` when Facebook returns an error"""
self.fb_error_code = str(fb_error_code) self.fb_error_code = str(fb_error_code)
self.fb_error_message = fb_error_message self.fb_error_message = fb_error_message
self.request_status_code = request_status_code self.request_status_code = request_status_code
class FBchatInvalidParameters(FBchatFacebookError):
"""Raised by Facebook if:
- Some function supplied invalid parameters.
- Some content is not found.
- Some content is no longer available.
"""
class FBchatNotLoggedIn(FBchatFacebookError):
"""Raised by Facebook if the client has been logged out."""
fb_error_code = "1357001"
class FBchatPleaseRefresh(FBchatFacebookError):
"""Raised by Facebook if the client has been inactive for too long.
This error usually happens after 1-2 days of inactivity.
"""
fb_error_code = "1357004"
fb_error_message = "Please try closing and re-opening your browser window."
class FBchatUserError(FBchatException): class FBchatUserError(FBchatException):
"""Thrown by fbchat when wrong values are entered""" """Thrown by ``fbchat`` when wrong values are entered."""

View File

@@ -7,9 +7,9 @@ from ._attachment import Attachment
@attr.s(cmp=False) @attr.s(cmp=False)
class FileAttachment(Attachment): class FileAttachment(Attachment):
"""Represents a file that has been sent as a Facebook attachment""" """Represents a file that has been sent as a Facebook attachment."""
#: Url where you can download the file #: URL where you can download the file
url = attr.ib(None) url = attr.ib(None)
#: Size of the file in bytes #: Size of the file in bytes
size = attr.ib(None) size = attr.ib(None)
@@ -33,13 +33,13 @@ class FileAttachment(Attachment):
@attr.s(cmp=False) @attr.s(cmp=False)
class AudioAttachment(Attachment): class AudioAttachment(Attachment):
"""Represents an audio file that has been sent as a Facebook attachment""" """Represents an audio file that has been sent as a Facebook attachment."""
#: Name of the file #: Name of the file
filename = attr.ib(None) filename = attr.ib(None)
#: Url of the audio file #: URL of the audio file
url = attr.ib(None) url = attr.ib(None)
#: Duration of the audioclip in milliseconds #: Duration of the audio clip in milliseconds
duration = attr.ib(None) duration = attr.ib(None)
#: Audio type #: Audio type
audio_type = attr.ib(None) audio_type = attr.ib(None)
@@ -59,13 +59,13 @@ class AudioAttachment(Attachment):
@attr.s(cmp=False, init=False) @attr.s(cmp=False, init=False)
class ImageAttachment(Attachment): class ImageAttachment(Attachment):
"""Represents an image that has been sent as a Facebook attachment """Represents an image that has been sent as a Facebook attachment.
To retrieve the full image url, use: :func:`fbchat.Client.fetchImageUrl`, and pass To retrieve the full image URL, use: `Client.fetchImageUrl`, and pass it the id of
it the uid of the image attachment the image attachment.
""" """
#: The extension of the original image (eg. 'png') #: The extension of the original image (e.g. ``png``)
original_extension = attr.ib(None) original_extension = attr.ib(None)
#: Width of original image #: Width of original image
width = attr.ib(None, converter=lambda x: None if x is None else int(x)) width = attr.ib(None, converter=lambda x: None if x is None else int(x))
@@ -92,7 +92,7 @@ class ImageAttachment(Attachment):
#: Height of the large preview image #: Height of the large preview image
large_preview_height = attr.ib(None) large_preview_height = attr.ib(None)
#: URL to an animated preview of the image (eg. for gifs) #: URL to an animated preview of the image (e.g. for GIFs)
animated_preview_url = attr.ib(None) animated_preview_url = attr.ib(None)
#: Width of the animated preview image #: Width of the animated preview image
animated_preview_width = attr.ib(None) animated_preview_width = attr.ib(None)
@@ -155,10 +155,22 @@ class ImageAttachment(Attachment):
uid=data.get("legacy_attachment_id"), uid=data.get("legacy_attachment_id"),
) )
@classmethod
def _from_list(cls, data):
data = data["node"]
return cls(
width=data["original_dimensions"].get("x"),
height=data["original_dimensions"].get("y"),
thumbnail_url=data["image"].get("uri"),
large_preview=data["image2"],
preview=data["image1"],
uid=data["legacy_attachment_id"],
)
@attr.s(cmp=False, init=False) @attr.s(cmp=False, init=False)
class VideoAttachment(Attachment): class VideoAttachment(Attachment):
"""Represents a video that has been sent as a Facebook attachment""" """Represents a video that has been sent as a Facebook attachment."""
#: Size of the original video in bytes #: Size of the original video in bytes
size = attr.ib(None) size = attr.ib(None)
@@ -252,6 +264,18 @@ class VideoAttachment(Attachment):
uid=data["target"].get("video_id"), uid=data["target"].get("video_id"),
) )
@classmethod
def _from_list(cls, data):
data = data["node"]
return cls(
width=data["original_dimensions"].get("x"),
height=data["original_dimensions"].get("y"),
small_image=data["image"],
medium_image=data["image1"],
large_image=data["image2"],
uid=data["legacy_attachment_id"],
)
def graphql_to_attachment(data): def graphql_to_attachment(data):
_type = data["__typename"] _type = data["__typename"]

View File

@@ -4,7 +4,7 @@ from __future__ import unicode_literals
import json import json
import re import re
from . import _util from . import _util
from ._exception import FBchatException, FBchatUserError from ._exception import FBchatException
# Shameless copy from https://stackoverflow.com/a/8730674 # Shameless copy from https://stackoverflow.com/a/8730674
FLAGS = re.VERBOSE | re.MULTILINE | re.DOTALL FLAGS = re.VERBOSE | re.MULTILINE | re.DOTALL
@@ -27,18 +27,18 @@ class ConcatJSONDecoder(json.JSONDecoder):
# End shameless copy # End shameless copy
def graphql_queries_to_json(*queries): def queries_to_json(*queries):
""" """
Queries should be a list of GraphQL objects Queries should be a list of GraphQL objects
""" """
rtn = {} rtn = {}
for i, query in enumerate(queries): for i, query in enumerate(queries):
rtn["q{}".format(i)] = query.value rtn["q{}".format(i)] = query
return json.dumps(rtn) return json.dumps(rtn)
def graphql_response_to_json(content): def response_to_json(content):
content = _util.strip_to_json(content) # Usually only needed in some error cases content = _util.strip_json_cruft(content) # Usually only needed in some error cases
try: try:
j = json.loads(content, cls=ConcatJSONDecoder) j = json.loads(content, cls=ConcatJSONDecoder)
except Exception: except Exception:
@@ -49,9 +49,9 @@ def graphql_response_to_json(content):
if "error_results" in x: if "error_results" in x:
del rtn[-1] del rtn[-1]
continue continue
_util.check_json(x) _util.handle_payload_error(x)
[(key, value)] = x.items() [(key, value)] = x.items()
_util.check_json(value) _util.handle_graphql_errors(value)
if "response" in value: if "response" in value:
rtn[int(key[1:])] = value["response"] rtn[int(key[1:])] = value["response"]
else: else:
@@ -62,171 +62,176 @@ def graphql_response_to_json(content):
return rtn return rtn
class GraphQL(object): def from_query(query, params):
def __init__(self, query=None, doc_id=None, params=None): return {"priority": 0, "q": query, "query_params": params}
if params is None:
params = {}
if query is not None:
self.value = {"priority": 0, "q": query, "query_params": params}
elif doc_id is not None:
self.value = {"doc_id": doc_id, "query_params": params}
else:
raise FBchatUserError("A query or doc_id must be specified")
FRAGMENT_USER = """
QueryFragment User: User {
id,
name,
first_name,
last_name,
profile_picture.width(<pic_size>).height(<pic_size>) {
uri
},
is_viewer_friend,
url,
gender,
viewer_affinity
}
"""
FRAGMENT_GROUP = """ def from_query_id(query_id, params):
QueryFragment Group: MessageThread { return {"query_id": query_id, "query_params": params}
name,
thread_key {
thread_fbid def from_doc(doc, params):
}, return {"doc": doc, "query_params": params}
image {
uri
}, def from_doc_id(doc_id, params):
is_group_thread, return {"doc_id": doc_id, "query_params": params}
all_participants {
nodes {
messaging_actor { FRAGMENT_USER = """
id QueryFragment User: User {
} id,
name,
first_name,
last_name,
profile_picture.width(<pic_size>).height(<pic_size>) {
uri
},
is_viewer_friend,
url,
gender,
viewer_affinity
}
"""
FRAGMENT_GROUP = """
QueryFragment Group: MessageThread {
name,
thread_key {
thread_fbid
},
image {
uri
},
is_group_thread,
all_participants {
nodes {
messaging_actor {
id
} }
}
},
customization_info {
participant_customizations {
participant_id,
nickname
}, },
customization_info { outgoing_bubble_color,
participant_customizations { emoji
participant_id, },
nickname thread_admins {
id
},
group_approval_queue {
nodes {
requester {
id
}
}
},
approval_mode,
joinable_mode {
mode,
link
},
event_reminders {
nodes {
id,
lightweight_event_creator {
id
}, },
outgoing_bubble_color, time,
emoji location_name,
}, event_title,
thread_admins { event_reminder_members {
id edges {
}, node {
group_approval_queue { id
},
guest_list_state
}
}
}
}
}
"""
FRAGMENT_PAGE = """
QueryFragment Page: Page {
id,
name,
profile_picture.width(32).height(32) {
uri
},
url,
category_type,
city {
name
}
}
"""
SEARCH_USER = (
"""
Query SearchUser(<search> = '', <limit> = 10) {
entities_named(<search>) {
search_results.of_type(user).first(<limit>) as users {
nodes { nodes {
requester { @User
id
}
} }
}, }
approval_mode, }
joinable_mode { }
mode, """
link + FRAGMENT_USER
}, )
event_reminders {
SEARCH_GROUP = (
"""
Query SearchGroup(<search> = '', <limit> = 10, <pic_size> = 32) {
viewer() {
message_threads.with_thread_name(<search>).last(<limit>) as groups {
nodes { nodes {
id, @Group
lightweight_event_creator {
id
},
time,
location_name,
event_title,
event_reminder_members {
edges {
node {
id
},
guest_list_state
}
}
} }
} }
} }
""" }
"""
+ FRAGMENT_GROUP
)
FRAGMENT_PAGE = """ SEARCH_PAGE = (
QueryFragment Page: Page {
id,
name,
profile_picture.width(32).height(32) {
uri
},
url,
category_type,
city {
name
}
}
""" """
Query SearchPage(<search> = '', <limit> = 10) {
SEARCH_USER = ( entities_named(<search>) {
""" search_results.of_type(page).first(<limit>) as pages {
Query SearchUser(<search> = '', <limit> = 10) { nodes {
entities_named(<search>) { @Page
search_results.of_type(user).first(<limit>) as users {
nodes {
@User
}
} }
} }
} }
""" }
+ FRAGMENT_USER """
) + FRAGMENT_PAGE
)
SEARCH_GROUP = ( SEARCH_THREAD = (
""" """
Query SearchGroup(<search> = '', <limit> = 10, <pic_size> = 32) { Query SearchThread(<search> = '', <limit> = 10) {
viewer() { entities_named(<search>) {
message_threads.with_thread_name(<search>).last(<limit>) as groups { search_results.first(<limit>) as threads {
nodes { nodes {
@Group __typename,
} @User,
@Group,
@Page
} }
} }
} }
""" }
+ FRAGMENT_GROUP """
) + FRAGMENT_USER
+ FRAGMENT_GROUP
SEARCH_PAGE = ( + FRAGMENT_PAGE
""" )
Query SearchPage(<search> = '', <limit> = 10) {
entities_named(<search>) {
search_results.of_type(page).first(<limit>) as pages {
nodes {
@Page
}
}
}
}
"""
+ FRAGMENT_PAGE
)
SEARCH_THREAD = (
"""
Query SearchThread(<search> = '', <limit> = 10) {
entities_named(<search>) {
search_results.first(<limit>) as threads {
nodes {
__typename,
@User,
@Group,
@Page
}
}
}
}
"""
+ FRAGMENT_USER
+ FRAGMENT_GROUP
+ FRAGMENT_PAGE
)

View File

@@ -8,11 +8,11 @@ from ._thread import ThreadType, Thread
@attr.s(cmp=False, init=False) @attr.s(cmp=False, init=False)
class Group(Thread): class Group(Thread):
"""Represents a Facebook group. Inherits `Thread`""" """Represents a Facebook group. Inherits `Thread`."""
#: Unique list (set) of the group thread's participant user IDs #: Unique list (set) of the group thread's participant user IDs
participants = attr.ib(factory=set, converter=lambda x: set() if x is None else x) participants = attr.ib(factory=set, converter=lambda x: set() if x is None else x)
#: A dict, containing user nicknames mapped to their IDs #: A dictionary, containing user nicknames mapped to their IDs
nicknames = attr.ib(factory=dict, converter=lambda x: {} if x is None else x) nicknames = attr.ib(factory=dict, converter=lambda x: {} if x is None else x)
#: A :class:`ThreadColor`. The groups's message color #: A :class:`ThreadColor`. The groups's message color
color = attr.ib(None) color = attr.ib(None)
@@ -104,10 +104,13 @@ class Group(Thread):
plan=plan, plan=plan,
) )
def _to_send_data(self):
return {"thread_fbid": self.uid}
@attr.s(cmp=False, init=False) @attr.s(cmp=False, init=False)
class Room(Group): class Room(Group):
"""Deprecated. Use :class:`Group` instead""" """Deprecated. Use `Group` instead."""
# True is room is not discoverable # True is room is not discoverable
privacy_mode = attr.ib(None) privacy_mode = attr.ib(None)

View File

@@ -8,9 +8,9 @@ from . import _util
@attr.s(cmp=False) @attr.s(cmp=False)
class LocationAttachment(Attachment): class LocationAttachment(Attachment):
"""Represents a user location """Represents a user location.
Latitude and longitude OR address is provided by Facebook Latitude and longitude OR address is provided by Facebook.
""" """
#: Latitude of the location #: Latitude of the location
@@ -58,7 +58,7 @@ class LocationAttachment(Attachment):
@attr.s(cmp=False, init=False) @attr.s(cmp=False, init=False)
class LiveLocationAttachment(LocationAttachment): class LiveLocationAttachment(LocationAttachment):
"""Represents a live user location""" """Represents a live user location."""
#: Name of the location #: Name of the location
name = attr.ib(None) name = attr.ib(None)

View File

@@ -9,7 +9,7 @@ from ._core import Enum
class EmojiSize(Enum): class EmojiSize(Enum):
"""Used to specify the size of a sent emoji""" """Used to specify the size of a sent emoji."""
LARGE = "369239383222810" LARGE = "369239383222810"
MEDIUM = "369239343222814" MEDIUM = "369239343222814"
@@ -26,15 +26,16 @@ class EmojiSize(Enum):
"s": cls.SMALL, "s": cls.SMALL,
} }
for tag in tags or (): for tag in tags or ():
data = tag.split(":", maxsplit=1) data = tag.split(":", 1)
if len(data) > 1 and data[0] == "hot_emoji_size": if len(data) > 1 and data[0] == "hot_emoji_size":
return string_to_emojisize.get(data[1]) return string_to_emojisize.get(data[1])
return None return None
class MessageReaction(Enum): class MessageReaction(Enum):
"""Used to specify a message reaction""" """Used to specify a message reaction."""
HEART = ""
LOVE = "😍" LOVE = "😍"
SMILE = "😆" SMILE = "😆"
WOW = "😮" WOW = "😮"
@@ -46,7 +47,7 @@ class MessageReaction(Enum):
@attr.s(cmp=False) @attr.s(cmp=False)
class Mention(object): class Mention(object):
"""Represents a @mention""" """Represents a ``@mention``."""
#: The thread ID the mention is pointing at #: The thread ID the mention is pointing at
thread_id = attr.ib() thread_id = attr.ib()
@@ -58,7 +59,7 @@ class Mention(object):
@attr.s(cmp=False) @attr.s(cmp=False)
class Message(object): class Message(object):
"""Represents a Facebook message""" """Represents a Facebook message."""
#: The actual message #: The actual message
text = attr.ib(None) text = attr.ib(None)
@@ -74,9 +75,9 @@ class Message(object):
timestamp = attr.ib(None, init=False) timestamp = attr.ib(None, init=False)
#: Whether the message is read #: Whether the message is read
is_read = attr.ib(None, init=False) is_read = attr.ib(None, init=False)
#: A list of pepole IDs who read the message, works only with :func:`fbchat.Client.fetchThreadMessages` #: A list of people IDs who read the message, works only with :func:`fbchat.Client.fetchThreadMessages`
read_by = attr.ib(factory=list, init=False) read_by = attr.ib(factory=list, init=False)
#: A dict with user's IDs as keys, and their :class:`MessageReaction` as values #: A dictionary with user's IDs as keys, and their :class:`MessageReaction` as values
reactions = attr.ib(factory=dict, init=False) reactions = attr.ib(factory=dict, init=False)
#: A :class:`Sticker` #: A :class:`Sticker`
sticker = attr.ib(None) sticker = attr.ib(None)
@@ -97,15 +98,13 @@ class Message(object):
def formatMentions(cls, text, *args, **kwargs): def formatMentions(cls, text, *args, **kwargs):
"""Like `str.format`, but takes tuples with a thread id and text instead. """Like `str.format`, but takes tuples with a thread id and text instead.
Returns a `Message` object, with the formatted string and relevant mentions. Return a `Message` object, with the formatted string and relevant mentions.
```
>>> Message.formatMentions("Hey {!r}! My name is {}", ("1234", "Peter"), ("4321", "Michael")) >>> Message.formatMentions("Hey {!r}! My name is {}", ("1234", "Peter"), ("4321", "Michael"))
<Message (None): "Hey 'Peter'! My name is Michael", mentions=[<Mention 1234: offset=4 length=7>, <Mention 4321: offset=24 length=7>] emoji_size=None attachments=[]> <Message (None): "Hey 'Peter'! My name is Michael", mentions=[<Mention 1234: offset=4 length=7>, <Mention 4321: offset=24 length=7>] emoji_size=None attachments=[]>
>>> Message.formatMentions("Hey {p}! My name is {}", ("1234", "Michael"), p=("4321", "Peter")) >>> Message.formatMentions("Hey {p}! My name is {}", ("1234", "Michael"), p=("4321", "Peter"))
<Message (None): 'Hey Peter! My name is Michael', mentions=[<Mention 4321: offset=4 length=5>, <Mention 1234: offset=22 length=7>] emoji_size=None attachments=[]> <Message (None): 'Hey Peter! My name is Michael', mentions=[<Mention 4321: offset=4 length=5>, <Mention 1234: offset=22 length=7>] emoji_size=None attachments=[]>
```
""" """
result = "" result = ""
mentions = list() mentions = list()
@@ -152,6 +151,55 @@ class Message(object):
return False return False
return any(map(lambda tag: "forward" in tag or "copy" in tag, tags)) return any(map(lambda tag: "forward" in tag or "copy" in tag, tags))
def _to_send_data(self):
data = {}
if self.text or self.sticker or self.emoji_size:
data["action_type"] = "ma-type:user-generated-message"
if self.text:
data["body"] = self.text
for i, mention in enumerate(self.mentions):
data["profile_xmd[{}][id]".format(i)] = mention.thread_id
data["profile_xmd[{}][offset]".format(i)] = mention.offset
data["profile_xmd[{}][length]".format(i)] = mention.length
data["profile_xmd[{}][type]".format(i)] = "p"
if self.emoji_size:
if self.text:
data["tags[0]"] = "hot_emoji_size:" + self.emoji_size.name.lower()
else:
data["sticker_id"] = self.emoji_size.value
if self.sticker:
data["sticker_id"] = self.sticker.uid
if self.quick_replies:
xmd = {"quick_replies": []}
for quick_reply in self.quick_replies:
# TODO: Move this to `_quick_reply.py`
q = dict()
q["content_type"] = quick_reply._type
q["payload"] = quick_reply.payload
q["external_payload"] = quick_reply.external_payload
q["data"] = quick_reply.data
if quick_reply.is_response:
q["ignore_for_webhook"] = False
if isinstance(quick_reply, _quick_reply.QuickReplyText):
q["title"] = quick_reply.title
if not isinstance(quick_reply, _quick_reply.QuickReplyLocation):
q["image_url"] = quick_reply.image_url
xmd["quick_replies"].append(q)
if len(self.quick_replies) == 1 and self.quick_replies[0].is_response:
xmd["quick_replies"] = xmd["quick_replies"][0]
data["platform_xmd"] = json.dumps(xmd)
if self.reply_to_id:
data["replied_to_message_id"] = self.reply_to_id
return data
@classmethod @classmethod
def _from_graphql(cls, data): def _from_graphql(cls, data):
if data.get("message_sender") is None: if data.get("message_sender") is None:

308
fbchat/_mqtt.py Normal file
View File

@@ -0,0 +1,308 @@
import attr
import random
import paho.mqtt.client
from ._core import log
from . import _util, _exception, _graphql
def generate_session_id():
"""Generate a random session ID between 1 and 9007199254740991."""
return random.randint(1, 2 ** 53)
@attr.s(slots=True)
class Mqtt(object):
_state = attr.ib()
_mqtt = attr.ib()
_on_message = attr.ib()
_chat_on = attr.ib()
_foreground = attr.ib()
_sequence_id = attr.ib()
_sync_token = attr.ib(None)
_HOST = "edge-chat.facebook.com"
@classmethod
def connect(cls, state, on_message, chat_on, foreground):
mqtt = paho.mqtt.client.Client(
client_id="mqttwsclient",
clean_session=True,
protocol=paho.mqtt.client.MQTTv31,
transport="websockets",
)
mqtt.enable_logger()
# mqtt.max_inflight_messages_set(20) # The rest will get queued
# mqtt.max_queued_messages_set(0) # Unlimited messages can be queued
# mqtt.message_retry_set(20) # Retry sending for at least 20 seconds
# mqtt.reconnect_delay_set(min_delay=1, max_delay=120)
# TODO: Is region (lla | atn | odn | others?) important?
mqtt.tls_set()
self = cls(
state=state,
mqtt=mqtt,
on_message=on_message,
chat_on=chat_on,
foreground=foreground,
sequence_id=cls._fetch_sequence_id(state),
)
# Configure callbacks
mqtt.on_message = self._on_message_handler
mqtt.on_connect = self._on_connect_handler
self._configure_connect_options()
# Attempt to connect
try:
rc = mqtt.connect(self._HOST, 443, keepalive=10)
except (
# Taken from .loop_forever
paho.mqtt.client.socket.error,
OSError,
paho.mqtt.client.WebsocketConnectionError,
) as e:
raise _exception.FBchatException("MQTT connection failed")
# Raise error if connecting failed
if rc != paho.mqtt.client.MQTT_ERR_SUCCESS:
err = paho.mqtt.client.error_string(rc)
raise _exception.FBchatException("MQTT connection failed: {}".format(err))
return self
def _on_message_handler(self, client, userdata, message):
# Parse payload JSON
try:
j = _util.parse_json(message.payload)
except _exception.FBchatFacebookError:
log.exception("Failed parsing MQTT data on %s as JSON", message.topic)
return
if message.topic == "/t_ms":
# Update sync_token when received
# This is received in the first message after we've created a messenger
# sync queue.
if "syncToken" in j and "firstDeltaSeqId" in j:
self._sync_token = j["syncToken"]
self._sequence_id = j["firstDeltaSeqId"]
# Update last sequence id when received
if "lastIssuedSeqId" in j:
self._sequence_id = j["lastIssuedSeqId"]
if "errorCode" in j:
# Known types: ERROR_QUEUE_OVERFLOW | ERROR_QUEUE_NOT_FOUND
# 'F\xfa\x84\x8c\x85\xf8\xbc-\x88 FB_PAGES_INSUFFICIENT_PERMISSION\x00'
log.error("MQTT error code %s received", j["errorCode"])
# TODO: Consider resetting the sync_token and sequence ID here?
log.debug("MQTT payload: %s, %s", message.topic, j)
# Call the external callback
self._on_message(message.topic, j)
@staticmethod
def _fetch_sequence_id(state):
"""Fetch sequence ID."""
params = {
"limit": 1,
"tags": ["INBOX"],
"before": None,
"includeDeliveryReceipts": False,
"includeSeqID": True,
}
log.debug("Fetching MQTT sequence ID")
# Same request as in `Client.fetchThreadList`
(j,) = state._graphql_requests(_graphql.from_doc_id("1349387578499440", params))
try:
return int(j["viewer"]["message_threads"]["sync_sequence_id"])
except (KeyError, ValueError):
# TODO: Proper exceptions
raise
def _on_connect_handler(self, client, userdata, flags, rc):
# configure receiving messages.
payload = {
"sync_api_version": 10,
"max_deltas_able_to_process": 1000,
"delta_batch_size": 500,
"encoding": "JSON",
"entity_fbid": self._state.user_id,
}
# If we don't have a sync_token, create a new messenger queue
# This is done so that across reconnects, if we've received a sync token, we
# SHOULD receive a piece of data in /t_ms exactly once!
if self._sync_token is None:
topic = "/messenger_sync_create_queue"
payload["initial_titan_sequence_id"] = str(self._sequence_id)
payload["device_params"] = None
else:
topic = "/messenger_sync_get_diffs"
payload["last_seq_id"] = str(self._sequence_id)
payload["sync_token"] = self._sync_token
self._mqtt.publish(topic, _util.json_minimal(payload), qos=1)
def _configure_connect_options(self):
# Generate a new session ID on each reconnect
session_id = generate_session_id()
topics = [
# Things that happen in chats (e.g. messages)
"/t_ms",
# Group typing notifications
"/thread_typing",
# Private chat typing notifications
"/orca_typing_notifications",
# Active notifications
"/orca_presence",
# Other notifications not related to chats (e.g. friend requests)
"/legacy_web",
# Facebook's continuous error reporting/logging?
"/br_sr",
# Response to /br_sr
"/sr_res",
# TODO: Investigate the response from this! (A bunch of binary data)
# "/t_p",
# TODO: Find out what this does!
"/webrtc",
# TODO: Find out what this does!
"/onevc",
# TODO: Find out what this does!
"/notify_disconnect",
# Old, no longer active topics
# These are here just in case something interesting pops up
"/inbox",
"/mercury",
"/messaging_events",
"/orca_message_notifications",
"/pp",
"/t_rtc",
"/webrtc_response",
]
username = {
# The user ID
"u": self._state.user_id,
# Session ID
"s": session_id,
# Active status setting
"chat_on": self._chat_on,
# foreground_state - Whether the window is focused
"fg": self._foreground,
# Can be any random ID
"d": self._state._client_id,
# Application ID, taken from facebook.com
"aid": 219994525426954,
# MQTT extension by FB, allows making a SUBSCRIBE while CONNECTing
"st": topics,
# MQTT extension by FB, allows making a PUBLISH while CONNECTing
# Using this is more efficient, but the same can be acheived with:
# def on_connect(*args):
# mqtt.publish(topic, payload, qos=1)
# mqtt.on_connect = on_connect
# TODO: For some reason this doesn't work!
"pm": [
# {
# "topic": topic,
# "payload": payload,
# "qos": 1,
# "messageId": 65536,
# }
],
# Unknown parameters
"cp": 3,
"ecp": 10,
"ct": "websocket",
"mqtt_sid": "",
"dc": "",
"no_auto_fg": True,
"gas": None,
"pack": [],
}
# TODO: Make this thread safe
self._mqtt.username_pw_set(_util.json_minimal(username))
headers = {
# TODO: Make this access thread safe
"Cookie": _util.get_cookie_header(self._state._session, self._HOST),
"User-Agent": self._state._session.headers["User-Agent"],
"Origin": "https://www.facebook.com",
"Host": self._HOST,
}
self._mqtt.ws_set_options(
path="/chat?sid={}".format(session_id), headers=headers
)
def loop_once(self, on_error=None):
"""Run the listening loop once.
Returns whether to keep listening or not.
"""
rc = self._mqtt.loop(timeout=1.0)
# If disconnect() has been called
if self._mqtt._state == paho.mqtt.client.mqtt_cs_disconnecting:
return False # Stop listening
if rc != paho.mqtt.client.MQTT_ERR_SUCCESS:
# If known/expected error
if rc == paho.mqtt.client.MQTT_ERR_CONN_LOST:
log.warning("Connection lost, retrying")
elif rc == paho.mqtt.client.MQTT_ERR_NOMEM:
# This error is wrongly classified
# See https://github.com/eclipse/paho.mqtt.python/issues/340
log.warning("Connection error, retrying")
else:
err = paho.mqtt.client.error_string(rc)
log.error("MQTT Error: %s", err)
# For backwards compatibility
if on_error:
on_error(_exception.FBchatException("MQTT Error {}".format(err)))
# Wait before reconnecting
self._mqtt._reconnect_wait()
# Try reconnecting
self._configure_connect_options()
try:
self._mqtt.reconnect()
except (
# Taken from .loop_forever
paho.mqtt.client.socket.error,
OSError,
paho.mqtt.client.WebsocketConnectionError,
) as e:
log.debug("MQTT reconnection failed: %s", e)
return True # Keep listening
def disconnect(self):
self._mqtt.disconnect()
def set_foreground(self, value):
payload = _util.json_minimal({"foreground": value})
info = self._mqtt.publish("/foreground_state", payload=payload, qos=1)
self._foreground = value
# TODO: We can't wait for this, since the loop is running with .loop_forever()
# info.wait_for_publish()
def set_chat_on(self, value):
# TODO: Is this the right request to make?
data = {"make_user_available_when_in_foreground": value}
payload = _util.json_minimal(data)
info = self._mqtt.publish("/set_client_settings", payload=payload, qos=1)
self._chat_on = value
# TODO: We can't wait for this, since the loop is running with .loop_forever()
# info.wait_for_publish()
# def send_additional_contacts(self, additional_contacts):
# payload = _util.json_minimal({"additional_contacts": additional_contacts})
# info = self._mqtt.publish("/send_additional_contacts", payload=payload, qos=1)
#
# def browser_close(self):
# info = self._mqtt.publish("/browser_close", payload=b"{}", qos=1)

View File

@@ -8,9 +8,9 @@ from ._thread import ThreadType, Thread
@attr.s(cmp=False, init=False) @attr.s(cmp=False, init=False)
class Page(Thread): class Page(Thread):
"""Represents a Facebook page. Inherits `Thread`""" """Represents a Facebook page. Inherits `Thread`."""
#: The page's custom url #: The page's custom URL
url = attr.ib(None) url = attr.ib(None)
#: The name of the page's location city #: The name of the page's location city
city = attr.ib(None) city = attr.ib(None)

View File

@@ -14,11 +14,11 @@ class GuestStatus(Enum):
@attr.s(cmp=False) @attr.s(cmp=False)
class Plan(object): class Plan(object):
"""Represents a plan""" """Represents a plan."""
#: ID of the plan #: ID of the plan
uid = attr.ib(None, init=False) uid = attr.ib(None, init=False)
#: Plan time (unix time stamp), only precise down to the minute #: Plan time (timestamp), only precise down to the minute
time = attr.ib(converter=int) time = attr.ib(converter=int)
#: Plan title #: Plan title
title = attr.ib() title = attr.ib()
@@ -28,7 +28,7 @@ class Plan(object):
location_id = attr.ib(None, converter=lambda x: x or "") location_id = attr.ib(None, converter=lambda x: x or "")
#: ID of the plan creator #: ID of the plan creator
author_id = attr.ib(None, init=False) author_id = attr.ib(None, init=False)
#: Dict of `User` IDs mapped to their `GuestStatus` #: Dictionary of `User` IDs mapped to their `GuestStatus`
guests = attr.ib(None, init=False) guests = attr.ib(None, init=False)
@property @property

View File

@@ -6,7 +6,7 @@ import attr
@attr.s(cmp=False) @attr.s(cmp=False)
class Poll(object): class Poll(object):
"""Represents a poll""" """Represents a poll."""
#: Title of the poll #: Title of the poll
title = attr.ib() title = attr.ib()
@@ -29,7 +29,7 @@ class Poll(object):
@attr.s(cmp=False) @attr.s(cmp=False)
class PollOption(object): class PollOption(object):
"""Represents a poll option""" """Represents a poll option."""
#: Text of the poll option #: Text of the poll option
text = attr.ib() text = attr.ib()

View File

@@ -7,7 +7,7 @@ from ._attachment import Attachment
@attr.s(cmp=False) @attr.s(cmp=False)
class QuickReply(object): class QuickReply(object):
"""Represents a quick reply""" """Represents a quick reply."""
#: Payload of the quick reply #: Payload of the quick reply
payload = attr.ib(None) payload = attr.ib(None)
@@ -21,7 +21,7 @@ class QuickReply(object):
@attr.s(cmp=False, init=False) @attr.s(cmp=False, init=False)
class QuickReplyText(QuickReply): class QuickReplyText(QuickReply):
"""Represents a text quick reply""" """Represents a text quick reply."""
#: Title of the quick reply #: Title of the quick reply
title = attr.ib(None) title = attr.ib(None)
@@ -38,7 +38,7 @@ class QuickReplyText(QuickReply):
@attr.s(cmp=False, init=False) @attr.s(cmp=False, init=False)
class QuickReplyLocation(QuickReply): class QuickReplyLocation(QuickReply):
"""Represents a location quick reply (Doesn't work on mobile)""" """Represents a location quick reply (Doesn't work on mobile)."""
#: Type of the quick reply #: Type of the quick reply
_type = "location" _type = "location"
@@ -50,7 +50,7 @@ class QuickReplyLocation(QuickReply):
@attr.s(cmp=False, init=False) @attr.s(cmp=False, init=False)
class QuickReplyPhoneNumber(QuickReply): class QuickReplyPhoneNumber(QuickReply):
"""Represents a phone number quick reply (Doesn't work on mobile)""" """Represents a phone number quick reply (Doesn't work on mobile)."""
#: URL of the quick reply image (optional) #: URL of the quick reply image (optional)
image_url = attr.ib(None) image_url = attr.ib(None)
@@ -64,7 +64,7 @@ class QuickReplyPhoneNumber(QuickReply):
@attr.s(cmp=False, init=False) @attr.s(cmp=False, init=False)
class QuickReplyEmail(QuickReply): class QuickReplyEmail(QuickReply):
"""Represents an email quick reply (Doesn't work on mobile)""" """Represents an email quick reply (Doesn't work on mobile)."""
#: URL of the quick reply image (optional) #: URL of the quick reply image (optional)
image_url = attr.ib(None) image_url = attr.ib(None)

331
fbchat/_state.py Normal file
View File

@@ -0,0 +1,331 @@
# -*- coding: UTF-8 -*-
from __future__ import unicode_literals
import attr
import bs4
import re
import requests
import random
from . import _graphql, _util, _exception
FB_DTSG_REGEX = re.compile(r'name="fb_dtsg" value="(.*?)"')
def get_user_id(session):
# TODO: Optimize this `.get_dict()` call!
rtn = session.cookies.get_dict().get("c_user")
if rtn is None:
raise _exception.FBchatException("Could not find user id")
return str(rtn)
def find_input_fields(html):
return bs4.BeautifulSoup(html, "html.parser", parse_only=bs4.SoupStrainer("input"))
def session_factory(user_agent=None):
session = requests.session()
session.headers["Referer"] = "https://www.facebook.com"
# TODO: Deprecate setting the user agent manually
session.headers["User-Agent"] = user_agent or random.choice(_util.USER_AGENTS)
return session
def client_id_factory():
return hex(int(random.random() * 2 ** 31))[2:]
def is_home(url):
parts = _util.urlparse(url)
# Check the urls `/home.php` and `/`
return "home" in parts.path or "/" == parts.path
def _2fa_helper(session, code, r):
soup = find_input_fields(r.text)
data = dict()
url = "https://m.facebook.com/login/checkpoint/"
data["approvals_code"] = code
data["fb_dtsg"] = soup.find("input", {"name": "fb_dtsg"})["value"]
data["nh"] = soup.find("input", {"name": "nh"})["value"]
data["submit[Submit Code]"] = "Submit Code"
data["codes_submitted"] = 0
_util.log.info("Submitting 2FA code.")
r = session.post(url, data=data)
if is_home(r.url):
return r
del data["approvals_code"]
del data["submit[Submit Code]"]
del data["codes_submitted"]
data["name_action_selected"] = "save_device"
data["submit[Continue]"] = "Continue"
_util.log.info("Saving browser.")
# At this stage, we have dtsg, nh, name_action_selected, submit[Continue]
r = session.post(url, data=data)
if is_home(r.url):
return r
del data["name_action_selected"]
_util.log.info("Starting Facebook checkup flow.")
# At this stage, we have dtsg, nh, submit[Continue]
r = session.post(url, data=data)
if is_home(r.url):
return r
del data["submit[Continue]"]
data["submit[This was me]"] = "This Was Me"
_util.log.info("Verifying login attempt.")
# At this stage, we have dtsg, nh, submit[This was me]
r = session.post(url, data=data)
if is_home(r.url):
return r
del data["submit[This was me]"]
data["submit[Continue]"] = "Continue"
data["name_action_selected"] = "save_device"
_util.log.info("Saving device again.")
# At this stage, we have dtsg, nh, submit[Continue], name_action_selected
r = session.post(url, data=data)
return r
@attr.s(slots=True) # TODO i Python 3: Add kw_only=True
class State(object):
"""Stores and manages state required for most Facebook requests."""
user_id = attr.ib()
_fb_dtsg = attr.ib()
_revision = attr.ib()
_session = attr.ib(factory=session_factory)
_counter = attr.ib(0)
_client_id = attr.ib(factory=client_id_factory)
_logout_h = attr.ib(None)
def get_params(self):
self._counter += 1 # TODO: Make this operation atomic / thread-safe
return {
"__a": 1,
"__req": _util.str_base(self._counter, 36),
"__rev": self._revision,
"fb_dtsg": self._fb_dtsg,
}
@classmethod
def login(cls, email, password, on_2fa_callback, user_agent=None):
session = session_factory(user_agent=user_agent)
soup = find_input_fields(session.get("https://m.facebook.com/").text)
data = dict(
(elem["name"], elem["value"])
for elem in soup
if elem.has_attr("value") and elem.has_attr("name")
)
data["email"] = email
data["pass"] = password
data["login"] = "Log In"
r = session.post("https://m.facebook.com/login.php?login_attempt=1", data=data)
# Usually, 'Checkpoint' will refer to 2FA
if "checkpoint" in r.url and ('id="approvals_code"' in r.text.lower()):
code = on_2fa_callback()
r = _2fa_helper(session, code, r)
# Sometimes Facebook tries to show the user a "Save Device" dialog
if "save-device" in r.url:
r = session.get("https://m.facebook.com/login/save-device/cancel/")
if is_home(r.url):
return cls.from_session(session=session)
else:
raise _exception.FBchatUserError(
"Login failed. Check email/password. "
"(Failed on url: {})".format(r.url)
)
def is_logged_in(self):
# Send a request to the login url, to see if we're directed to the home page
url = "https://m.facebook.com/login.php?login_attempt=1"
r = self._session.get(url, allow_redirects=False)
return "Location" in r.headers and is_home(r.headers["Location"])
def logout(self):
logout_h = self._logout_h
if not logout_h:
url = _util.prefix_url("/bluebar/modern_settings_menu/")
h_r = self._session.post(url, data={"pmid": "4"})
logout_h = re.search(r'name=\\"h\\" value=\\"(.*?)\\"', h_r.text).group(1)
url = _util.prefix_url("/logout.php")
return self._session.get(url, params={"ref": "mb", "h": logout_h}).ok
@classmethod
def from_session(cls, session):
# TODO: Automatically set user_id when the cookie changes in the session
user_id = get_user_id(session)
r = session.get(_util.prefix_url("/"))
soup = find_input_fields(r.text)
fb_dtsg_element = soup.find("input", {"name": "fb_dtsg"})
if fb_dtsg_element:
fb_dtsg = fb_dtsg_element["value"]
else:
# Fall back to searching with a regex
fb_dtsg = FB_DTSG_REGEX.search(r.text).group(1)
revision = int(r.text.split('"client_revision":', 1)[1].split(",", 1)[0])
logout_h_element = soup.find("input", {"name": "h"})
logout_h = logout_h_element["value"] if logout_h_element else None
return cls(
user_id=user_id,
fb_dtsg=fb_dtsg,
revision=revision,
session=session,
logout_h=logout_h,
)
def get_cookies(self):
return self._session.cookies.get_dict()
@classmethod
def from_cookies(cls, cookies, user_agent=None):
session = session_factory(user_agent=user_agent)
session.cookies = requests.cookies.merge_cookies(session.cookies, cookies)
return cls.from_session(session=session)
def _do_refresh(self):
# TODO: Raise the error instead, and make the user do the refresh manually
# It may be a bad idea to do this in an exception handler, if you have a better method, please suggest it!
_util.log.warning("Refreshing state and resending request")
new = State.from_session(session=self._session)
self.user_id = new.user_id
self._fb_dtsg = new._fb_dtsg
self._revision = new._revision
self._counter = new._counter
self._logout_h = new._logout_h or self._logout_h
def _get(self, url, params, error_retries=3):
params.update(self.get_params())
r = self._session.get(_util.prefix_url(url), params=params)
content = _util.check_request(r)
j = _util.to_json(content)
try:
_util.handle_payload_error(j)
except _exception.FBchatPleaseRefresh:
if error_retries > 0:
self._do_refresh()
return self._get(url, params, error_retries=error_retries - 1)
raise
return j
def _post(self, url, data, files=None, as_graphql=False, error_retries=3):
data.update(self.get_params())
r = self._session.post(_util.prefix_url(url), data=data, files=files)
content = _util.check_request(r)
try:
if as_graphql:
return _graphql.response_to_json(content)
else:
j = _util.to_json(content)
# TODO: Remove this, and move it to _payload_post instead
# We can't yet, since errors raised in here need to be caught below
_util.handle_payload_error(j)
return j
except _exception.FBchatPleaseRefresh:
if error_retries > 0:
self._do_refresh()
return self._post(
url,
data,
files=files,
as_graphql=as_graphql,
error_retries=error_retries - 1,
)
raise
def _payload_post(self, url, data, files=None):
j = self._post(url, data, files=files)
try:
return j["payload"]
except (KeyError, TypeError):
raise _exception.FBchatException("Missing payload: {}".format(j))
def _graphql_requests(self, *queries):
data = {
"method": "GET",
"response_format": "json",
"queries": _graphql.queries_to_json(*queries),
}
return self._post("/api/graphqlbatch/", data, as_graphql=True)
def _upload(self, files, voice_clip=False):
"""Upload files to Facebook.
`files` should be a list of files that requests can upload, see
`requests.request <https://docs.python-requests.org/en/master/api/#requests.request>`_.
Return a list of tuples with a file's ID and mimetype.
"""
file_dict = {"upload_{}".format(i): f for i, f in enumerate(files)}
data = {"voice_clip": voice_clip}
j = self._payload_post(
"https://upload.facebook.com/ajax/mercury/upload.php", data, files=file_dict
)
if len(j["metadata"]) != len(files):
raise _exception.FBchatException(
"Some files could not be uploaded: {}, {}".format(j, files)
)
return [
(data[_util.mimetype_to_key(data["filetype"])], data["filetype"])
for data in j["metadata"]
]
def _do_send_request(self, data):
offline_threading_id = _util.generateOfflineThreadingID()
data["client"] = "mercury"
data["author"] = "fbid:{}".format(self.user_id)
data["timestamp"] = _util.now()
data["source"] = "source:chat:web"
data["offline_threading_id"] = offline_threading_id
data["message_id"] = offline_threading_id
data["threading_id"] = _util.generateMessageID(self._client_id)
data["ephemeral_ttl_mode:"] = "0"
j = self._post("/messaging/send/", data)
# update JS token if received in response
fb_dtsg = _util.get_jsmods_require(j, 2)
if fb_dtsg is not None:
self._fb_dtsg = fb_dtsg
try:
message_ids = [
(action["message_id"], action["thread_fbid"])
for action in j["payload"]["actions"]
if "message_id" in action
]
if len(message_ids) != 1:
log.warning("Got multiple message ids' back: {}".format(message_ids))
return message_ids[0]
except (KeyError, IndexError, TypeError) as e:
raise _exception.FBchatException(
"Error when sending message: "
"No message IDs could be found: {}".format(j)
)

View File

@@ -7,7 +7,7 @@ from ._attachment import Attachment
@attr.s(cmp=False, init=False) @attr.s(cmp=False, init=False)
class Sticker(Attachment): class Sticker(Attachment):
"""Represents a Facebook sticker that has been sent to a thread as an attachment""" """Represents a Facebook sticker that has been sent to a thread as an attachment."""
#: The sticker-pack's ID #: The sticker-pack's ID
pack = attr.ib(None) pack = attr.ib(None)
@@ -21,7 +21,7 @@ class Sticker(Attachment):
large_sprite_image = attr.ib(None) large_sprite_image = attr.ib(None)
#: The amount of frames present in the spritemap pr. row #: The amount of frames present in the spritemap pr. row
frames_per_row = attr.ib(None) frames_per_row = attr.ib(None)
#: The amount of frames present in the spritemap pr. coloumn #: The amount of frames present in the spritemap pr. column
frames_per_col = attr.ib(None) frames_per_col = attr.ib(None)
#: The frame rate the spritemap is intended to be played in #: The frame rate the spritemap is intended to be played in
frame_rate = attr.ib(None) frame_rate = attr.ib(None)

View File

@@ -6,13 +6,27 @@ from ._core import Enum
class ThreadType(Enum): class ThreadType(Enum):
"""Used to specify what type of Facebook thread is being used. See :ref:`intro_threads` for more info""" """Used to specify what type of Facebook thread is being used.
See :ref:`intro_threads` for more info.
"""
USER = 1 USER = 1
GROUP = 2 GROUP = 2
ROOM = 2 ROOM = 2
PAGE = 3 PAGE = 3
def _to_class(self):
"""Convert this enum value to the corresponding class."""
from . import _user, _group, _page
return {
ThreadType.USER: _user.User,
ThreadType.GROUP: _group.Group,
ThreadType.ROOM: _group.Room,
ThreadType.PAGE: _page.Page,
}[self]
class ThreadLocation(Enum): class ThreadLocation(Enum):
"""Used to specify where a thread is located (inbox, pending, archived, other).""" """Used to specify where a thread is located (inbox, pending, archived, other)."""
@@ -24,7 +38,7 @@ class ThreadLocation(Enum):
class ThreadColor(Enum): class ThreadColor(Enum):
"""Used to specify a thread colors""" """Used to specify a thread colors."""
MESSENGER_BLUE = "#0084ff" MESSENGER_BLUE = "#0084ff"
VIKING = "#44bec7" VIKING = "#44bec7"
@@ -60,13 +74,13 @@ class ThreadColor(Enum):
@attr.s(cmp=False, init=False) @attr.s(cmp=False, init=False)
class Thread(object): class Thread(object):
"""Represents a Facebook thread""" """Represents a Facebook thread."""
#: The unique identifier of the thread. Can be used a `thread_id`. See :ref:`intro_threads` for more info #: The unique identifier of the thread. Can be used a ``thread_id``. See :ref:`intro_threads` for more info
uid = attr.ib(converter=str) uid = attr.ib(converter=str)
#: Specifies the type of thread. Can be used a `thread_type`. See :ref:`intro_threads` for more info #: Specifies the type of thread. Can be used a ``thread_type``. See :ref:`intro_threads` for more info
type = attr.ib() type = attr.ib()
#: A url to the thread's picture #: A URL to the thread's picture
photo = attr.ib(None) photo = attr.ib(None)
#: The name of the thread #: The name of the thread
name = attr.ib(None) name = attr.ib(None)
@@ -127,3 +141,7 @@ class Thread(object):
else: else:
rtn["own_nickname"] = pc[1].get("nickname") rtn["own_nickname"] = pc[1].get("nickname")
return rtn return rtn
def _to_send_data(self):
# TODO: Only implement this in subclasses
return {"other_user_fbid": self.uid}

View File

@@ -38,7 +38,7 @@ GENDERS = {
class TypingStatus(Enum): class TypingStatus(Enum):
"""Used to specify whether the user is typing or has stopped typing""" """Used to specify whether the user is typing or has stopped typing."""
STOPPED = 0 STOPPED = 0
TYPING = 1 TYPING = 1
@@ -46,9 +46,9 @@ class TypingStatus(Enum):
@attr.s(cmp=False, init=False) @attr.s(cmp=False, init=False)
class User(Thread): class User(Thread):
"""Represents a Facebook user. Inherits `Thread`""" """Represents a Facebook user. Inherits `Thread`."""
#: The profile url #: The profile URL
url = attr.ib(None) url = attr.ib(None)
#: The users first name #: The users first name
first_name = attr.ib(None) first_name = attr.ib(None)
@@ -192,17 +192,6 @@ class ActiveStatus(object):
in_game = attr.ib(None) in_game = attr.ib(None)
@classmethod @classmethod
def _from_chatproxy_presence(cls, id_, data): def _from_orca_presence(cls, data):
return cls( # TODO: Handle `c` and `vc` keys (Probably some binary data)
active=data["p"] in [2, 3] if "p" in data else None, return cls(active=data["p"] in [2, 3], last_active=data.get("l"), in_game=None)
last_active=data.get("lat"),
in_game=int(id_) in data.get("gamers", {}),
)
@classmethod
def _from_buddylist_overlay(cls, data, in_game=None):
return cls(
active=data["a"] in [2, 3] if "a" in data else None,
last_active=data.get("la"),
in_game=None,
)

View File

@@ -11,7 +11,13 @@ from os.path import basename
import warnings import warnings
import logging import logging
import requests import requests
from ._exception import FBchatException, FBchatFacebookError from ._exception import (
FBchatException,
FBchatFacebookError,
FBchatInvalidParameters,
FBchatNotLoggedIn,
FBchatPleaseRefresh,
)
try: try:
from urllib.parse import urlencode, parse_qs, urlparse from urllib.parse import urlencode, parse_qs, urlparse
@@ -47,116 +53,43 @@ USER_AGENTS = [
] ]
class ReqUrl(object):
"""A class containing all urls used by `fbchat`"""
SEARCH = "https://www.facebook.com/ajax/typeahead/search.php"
LOGIN = "https://m.facebook.com/login.php?login_attempt=1"
SEND = "https://www.facebook.com/messaging/send/"
UNREAD_THREADS = "https://www.facebook.com/ajax/mercury/unread_threads.php"
UNSEEN_THREADS = "https://www.facebook.com/mercury/unseen_thread_ids/"
THREADS = "https://www.facebook.com/ajax/mercury/threadlist_info.php"
MOVE_THREAD = "https://www.facebook.com/ajax/mercury/move_thread.php"
ARCHIVED_STATUS = (
"https://www.facebook.com/ajax/mercury/change_archived_status.php?dpr=1"
)
PINNED_STATUS = (
"https://www.facebook.com/ajax/mercury/change_pinned_status.php?dpr=1"
)
MESSAGES = "https://www.facebook.com/ajax/mercury/thread_info.php"
READ_STATUS = "https://www.facebook.com/ajax/mercury/change_read_status.php"
DELIVERED = "https://www.facebook.com/ajax/mercury/delivery_receipts.php"
MARK_SEEN = "https://www.facebook.com/ajax/mercury/mark_seen.php"
BASE = "https://www.facebook.com"
MOBILE = "https://m.facebook.com/"
STICKY = "https://0-edge-chat.facebook.com/pull"
PING = "https://0-edge-chat.facebook.com/active_ping"
UPLOAD = "https://upload.facebook.com/ajax/mercury/upload.php"
INFO = "https://www.facebook.com/chat/user_info/"
CONNECT = "https://www.facebook.com/ajax/add_friend/action.php?dpr=1"
REMOVE_USER = "https://www.facebook.com/chat/remove_participants/"
LOGOUT = "https://www.facebook.com/logout.php"
ALL_USERS = "https://www.facebook.com/chat/user_info_all"
SAVE_DEVICE = "https://m.facebook.com/login/save-device/cancel/"
CHECKPOINT = "https://m.facebook.com/login/checkpoint/"
THREAD_COLOR = "https://www.facebook.com/messaging/save_thread_color/?source=thread_settings&dpr=1"
THREAD_NICKNAME = "https://www.facebook.com/messaging/save_thread_nickname/?source=thread_settings&dpr=1"
THREAD_EMOJI = "https://www.facebook.com/messaging/save_thread_emoji/?source=thread_settings&dpr=1"
THREAD_IMAGE = "https://www.facebook.com/messaging/set_thread_image/?dpr=1"
THREAD_NAME = "https://www.facebook.com/messaging/set_thread_name/?dpr=1"
MESSAGE_REACTION = "https://www.facebook.com/webgraphql/mutation"
TYPING = "https://www.facebook.com/ajax/messaging/typ.php"
GRAPHQL = "https://www.facebook.com/api/graphqlbatch/"
ATTACHMENT_PHOTO = "https://www.facebook.com/mercury/attachments/photo/"
PLAN_CREATE = "https://www.facebook.com/ajax/eventreminder/create"
PLAN_INFO = "https://www.facebook.com/ajax/eventreminder"
PLAN_CHANGE = "https://www.facebook.com/ajax/eventreminder/submit"
PLAN_PARTICIPATION = "https://www.facebook.com/ajax/eventreminder/rsvp"
MODERN_SETTINGS_MENU = "https://www.facebook.com/bluebar/modern_settings_menu/"
REMOVE_FRIEND = "https://m.facebook.com/a/removefriend.php"
BLOCK_USER = "https://www.facebook.com/messaging/block_messages/?dpr=1"
UNBLOCK_USER = "https://www.facebook.com/messaging/unblock_messages/?dpr=1"
SAVE_ADMINS = "https://www.facebook.com/messaging/save_admins/?dpr=1"
APPROVAL_MODE = "https://www.facebook.com/messaging/set_approval_mode/?dpr=1"
CREATE_GROUP = "https://m.facebook.com/messages/send/?icm=1"
DELETE_THREAD = "https://www.facebook.com/ajax/mercury/delete_thread.php?dpr=1"
DELETE_MESSAGES = "https://www.facebook.com/ajax/mercury/delete_messages.php?dpr=1"
MUTE_THREAD = "https://www.facebook.com/ajax/mercury/change_mute_thread.php?dpr=1"
MUTE_REACTIONS = (
"https://www.facebook.com/ajax/mercury/change_reactions_mute_thread/?dpr=1"
)
MUTE_MENTIONS = (
"https://www.facebook.com/ajax/mercury/change_mentions_mute_thread/?dpr=1"
)
CREATE_POLL = "https://www.facebook.com/messaging/group_polling/create_poll/?dpr=1"
UPDATE_VOTE = "https://www.facebook.com/messaging/group_polling/update_vote/?dpr=1"
GET_POLL_OPTIONS = "https://www.facebook.com/ajax/mercury/get_poll_options"
SEARCH_MESSAGES = "https://www.facebook.com/ajax/mercury/search_snippets.php?dpr=1"
MARK_SPAM = "https://www.facebook.com/ajax/mercury/mark_spam.php?dpr=1"
UNSEND = "https://www.facebook.com/messaging/unsend_message/?dpr=1"
FORWARD_ATTACHMENT = "https://www.facebook.com/mercury/attachments/forward/"
pull_channel = 0
def change_pull_channel(self, channel=None):
if channel is None:
self.pull_channel = (self.pull_channel + 1) % 5 # Pull channel will be 0-4
else:
self.pull_channel = channel
self.STICKY = "https://{}-edge-chat.facebook.com/pull".format(self.pull_channel)
self.PING = "https://{}-edge-chat.facebook.com/active_ping".format(
self.pull_channel
)
facebookEncoding = "UTF-8"
def now(): def now():
return int(time() * 1000) return int(time() * 1000)
def strip_to_json(text): def json_minimal(data):
"""Get JSON data in minimal form."""
return json.dumps(data, separators=(",", ":"))
def strip_json_cruft(text):
"""Removes `for(;;);` (and other cruft) that preceeds JSON responses."""
try: try:
return text[text.index("{") :] return text[text.index("{") :]
except ValueError: except ValueError:
raise FBchatException("No JSON object found: {!r}".format(text)) raise FBchatException("No JSON object found: {!r}".format(text))
def get_cookie_header(session, host):
"""Extract a cookie header from a requests session."""
return requests.cookies.get_cookie_header(
session.cookies, requests.Request("GET", host),
)
def get_decoded_r(r): def get_decoded_r(r):
return get_decoded(r._content) return get_decoded(r._content)
def get_decoded(content): def get_decoded(content):
return content.decode(facebookEncoding) return content.decode("utf-8")
def parse_json(content): def parse_json(content):
return json.loads(content) try:
return json.loads(content)
except ValueError:
def get_json(r): raise FBchatFacebookError("Error while parsing JSON: {!r}".format(content))
return json.loads(strip_to_json(get_decoded_r(r)))
def digitToChar(digit): def digitToChar(digit):
@@ -192,61 +125,74 @@ def generateOfflineThreadingID():
return str(int(msgs, 2)) return str(int(msgs, 2))
def check_json(j): def handle_payload_error(j):
if hasattr(j.get("payload"), "get") and j["payload"].get("error"): if "error" not in j:
raise FBchatFacebookError( return
"Error when sending request: {}".format(j["payload"]["error"]), error = j["error"]
fb_error_code=None, if j["error"] == 1357001:
fb_error_message=j["payload"]["error"], error_cls = FBchatNotLoggedIn
) elif j["error"] == 1357004:
elif j.get("error"): error_cls = FBchatPleaseRefresh
if "errorDescription" in j: elif j["error"] in (1357031, 1545010, 1545003):
# 'errorDescription' is in the users own language! error_cls = FBchatInvalidParameters
raise FBchatFacebookError( else:
"Error #{} when sending request: {}".format( error_cls = FBchatFacebookError
j["error"], j["errorDescription"] # TODO: Use j["errorSummary"]
), # "errorDescription" is in the users own language!
fb_error_code=j["error"], raise error_cls(
fb_error_message=j["errorDescription"], "Error #{} when sending request: {}".format(error, j["errorDescription"]),
) fb_error_code=error,
elif "debug_info" in j["error"] and "code" in j["error"]: fb_error_message=j["errorDescription"],
raise FBchatFacebookError( )
"Error #{} when sending request: {}".format(
j["error"]["code"], repr(j["error"]["debug_info"])
),
fb_error_code=j["error"]["code"],
fb_error_message=j["error"]["debug_info"],
)
else:
raise FBchatFacebookError(
"Error {} when sending request".format(j["error"]),
fb_error_code=j["error"],
)
def check_request(r, as_json=True): def handle_graphql_errors(j):
if not r.ok: errors = []
if j.get("error"):
errors = [j["error"]]
if "errors" in j:
errors = j["errors"]
if errors:
error = errors[0] # TODO: Handle multiple errors
# TODO: Use `summary`, `severity` and `description`
raise FBchatFacebookError( raise FBchatFacebookError(
"Error when sending request: Got {} response".format(r.status_code), "GraphQL error #{}: {} / {!r}".format(
request_status_code=r.status_code, error.get("code"), error.get("message"), error.get("debug_info")
),
fb_error_code=error.get("code"),
fb_error_message=error.get("message"),
) )
def check_request(r):
check_http_code(r.status_code)
content = get_decoded_r(r) content = get_decoded_r(r)
check_content(content)
return content
def check_http_code(code):
msg = "Error when sending request: Got {} response.".format(code)
if code == 404:
raise FBchatFacebookError(
msg + " This is either because you specified an invalid URL, or because"
" you provided an invalid id (Facebook usually requires integer ids).",
request_status_code=code,
)
if 400 <= code < 600:
raise FBchatFacebookError(msg, request_status_code=code)
def check_content(content, as_json=True):
if content is None or len(content) == 0: if content is None or len(content) == 0:
raise FBchatFacebookError("Error when sending request: Got empty response") raise FBchatFacebookError("Error when sending request: Got empty response")
if as_json:
content = strip_to_json(content) def to_json(content):
try: content = strip_json_cruft(content)
j = json.loads(content) j = parse_json(content)
except ValueError: log.debug(j)
raise FBchatFacebookError("Error while parsing JSON: {!r}".format(content)) return j
check_json(j)
log.debug(j)
return j
else:
return content
def get_jsmods_require(j, index): def get_jsmods_require(j, index):
@@ -255,9 +201,8 @@ def get_jsmods_require(j, index):
return j["jsmods"]["require"][0][index][0] return j["jsmods"]["require"][0][index][0]
except (KeyError, IndexError) as e: except (KeyError, IndexError) as e:
log.warning( log.warning(
"Error when getting jsmods_require: {}. Facebook might have changed protocol".format( "Error when getting jsmods_require: "
j "{}. Facebook might have changed protocol".format(j)
)
) )
return None return None
@@ -286,11 +231,12 @@ def get_files_from_urls(file_urls):
r = requests.get(file_url) r = requests.get(file_url)
# We could possibly use r.headers.get('Content-Disposition'), see # We could possibly use r.headers.get('Content-Disposition'), see
# https://stackoverflow.com/a/37060758 # https://stackoverflow.com/a/37060758
file_name = basename(file_url).split("?")[0].split("#")[0]
files.append( files.append(
( (
basename(file_url).split("?")[0].split("#")[0], file_name,
r.content, r.content,
r.headers.get("Content-Type") or guess_type(file_url)[0], r.headers.get("Content-Type") or guess_type(file_name)[0],
) )
) )
return files return files
@@ -315,3 +261,9 @@ def get_url_parameters(url, *args):
def get_url_parameter(url, param): def get_url_parameter(url, param):
return get_url_parameters(url, param)[0] return get_url_parameters(url, param)[0]
def prefix_url(url):
if url.startswith("/"):
return "https://www.facebook.com" + url
return url

View File

@@ -1,14 +1,5 @@
[tool.black] [tool.black]
line-length = 88 line-length = 88
exclude = '''
/(
\.git
| \.pytest_cache
| build
| dist
| venv
)/
'''
[build-system] [build-system]
requires = ["flit"] requires = ["flit"]
@@ -22,10 +13,11 @@ maintainer = "Mads Marquart"
maintainer-email = "madsmtm@gmail.com" maintainer-email = "madsmtm@gmail.com"
home-page = "https://github.com/carpedm20/fbchat/" home-page = "https://github.com/carpedm20/fbchat/"
requires = [ requires = [
"aenum", "aenum~=2.0",
"attrs~=18.2.0", "attrs>=18.2",
"requests", "requests~=2.19",
"beautifulsoup4", "beautifulsoup4~=4.0",
"paho-mqtt~=1.5",
] ]
description-file = "README.rst" description-file = "README.rst"
classifiers = [ classifiers = [
@@ -61,5 +53,16 @@ Repository = "https://github.com/carpedm20/fbchat/"
[tool.flit.metadata.requires-extra] [tool.flit.metadata.requires-extra]
test = [ test = [
"pytest~=4.0", "pytest~=4.0",
"six", "six~=1.0",
]
docs = [
"sphinx~=2.0",
"sphinxcontrib-spelling~=4.0"
]
lint = [
"black",
]
tools = [
# Fork of bumpversion, see https://github.com/c4urself/bump2version
"bump2version~=0.5.0",
] ]

View File

@@ -27,7 +27,7 @@ def test_fetch_threads(client1):
@pytest.mark.parametrize("emoji, emoji_size", EMOJI_LIST) @pytest.mark.parametrize("emoji, emoji_size", EMOJI_LIST)
def test_fetch_message_emoji(client, emoji, emoji_size): def test_fetch_message_emoji(client, emoji, emoji_size):
mid = client.sendEmoji(emoji, emoji_size) mid = client.sendEmoji(emoji, emoji_size)
message, = client.fetchThreadMessages(limit=1) (message,) = client.fetchThreadMessages(limit=1)
assert subset( assert subset(
vars(message), uid=mid, author=client.uid, text=emoji, emoji_size=emoji_size vars(message), uid=mid, author=client.uid, text=emoji, emoji_size=emoji_size
@@ -46,7 +46,7 @@ def test_fetch_message_info_emoji(client, thread, emoji, emoji_size):
def test_fetch_message_mentions(client, thread, message_with_mentions): def test_fetch_message_mentions(client, thread, message_with_mentions):
mid = client.send(message_with_mentions) mid = client.send(message_with_mentions)
message, = client.fetchThreadMessages(limit=1) (message,) = client.fetchThreadMessages(limit=1)
assert subset( assert subset(
vars(message), uid=mid, author=client.uid, text=message_with_mentions.text vars(message), uid=mid, author=client.uid, text=message_with_mentions.text
@@ -71,7 +71,7 @@ def test_fetch_message_info_mentions(client, thread, message_with_mentions):
@pytest.mark.parametrize("sticker", STICKER_LIST) @pytest.mark.parametrize("sticker", STICKER_LIST)
def test_fetch_message_sticker(client, sticker): def test_fetch_message_sticker(client, sticker):
mid = client.send(Message(sticker=sticker)) mid = client.send(Message(sticker=sticker))
message, = client.fetchThreadMessages(limit=1) (message,) = client.fetchThreadMessages(limit=1)
assert subset(vars(message), uid=mid, author=client.uid) assert subset(vars(message), uid=mid, author=client.uid)
assert subset(vars(message.sticker), uid=sticker.uid) assert subset(vars(message.sticker), uid=sticker.uid)
@@ -96,6 +96,6 @@ def test_fetch_info(client1, group):
def test_fetch_image_url(client): def test_fetch_image_url(client):
client.sendLocalFiles([path.join(path.dirname(__file__), "resources", "image.png")]) client.sendLocalFiles([path.join(path.dirname(__file__), "resources", "image.png")])
message, = client.fetchThreadMessages(limit=1) (message,) = client.fetchThreadMessages(limit=1)
assert client.fetchImageUrl(message.attachments[0].uid) assert client.fetchImageUrl(message.attachments[0].uid)

View File

@@ -19,5 +19,5 @@ def test_delete_messages(client):
mid1 = client.sendMessage(text1) mid1 = client.sendMessage(text1)
mid2 = client.sendMessage(text2) mid2 = client.sendMessage(text2)
client.deleteMessages(mid2) client.deleteMessages(mid2)
message, = client.fetchThreadMessages(limit=1) (message,) = client.fetchThreadMessages(limit=1)
assert subset(vars(message), uid=mid1, author=client.uid, text=text1) assert subset(vars(message), uid=mid1, author=client.uid, text=text1)

View File

@@ -63,7 +63,7 @@ def test_create_poll(client1, group, catch_event, poll_data):
for recv_option in event[ for recv_option in event[
"poll" "poll"
].options: # The recieved options may not be the full list ].options: # The recieved options may not be the full list
old_option, = list(filter(lambda o: o.text == recv_option.text, poll.options)) (old_option,) = list(filter(lambda o: o.text == recv_option.text, poll.options))
voters = [client1.uid] if old_option.vote else [] voters = [client1.uid] if old_option.vote else []
assert subset( assert subset(
vars(recv_option), voters=voters, votes_count=len(voters), vote=False vars(recv_option), voters=voters, votes_count=len(voters), vote=False