Compare commits
341 Commits
v1.7.2
...
769b034d38
| Author | SHA1 | Date | |
|---|---|---|---|
| 769b034d38 | |||
| fd3d5f7301 | |||
| 2fa1b58336 | |||
| 9523350dc5 | |||
| 356db553b7 | |||
| 55712756d7 | |||
|
|
916a14062d | ||
|
|
43aa16c32d | ||
|
|
427ae6bc5e | ||
|
|
d650946531 | ||
|
|
8ac6dc4ae6 | ||
|
|
a6cf1d5c89 | ||
|
|
65b42e6532 | ||
|
|
8824a1c253 | ||
|
|
520258e339 | ||
|
|
435dfaf6d8 | ||
|
|
cf0e1e3a93 | ||
|
|
2319fc7c4a | ||
|
|
b35240bdda | ||
|
|
6141cc5a41 | ||
|
|
b1e438dae1 | ||
|
|
3c0f411be7 | ||
|
|
9ad0090b02 | ||
|
|
bec151a560 | ||
|
|
2087182ecf | ||
|
|
09627b71ae | ||
|
|
078bf9fc16 | ||
|
|
d33e36866d | ||
|
|
2a382ffaed | ||
|
|
18a3ffb90d | ||
|
|
db284cefdf | ||
|
|
d11f417caa | ||
|
|
3b71258f2c | ||
|
|
81584d328b | ||
|
|
7be2acad7d | ||
|
|
079d4093c4 | ||
|
|
cce947b18c | ||
|
|
2545a01450 | ||
|
|
5d763dfbce | ||
|
|
0981be42b9 | ||
|
|
93b71bf198 | ||
|
|
af3758c8a9 | ||
|
|
f64c487a2d | ||
|
|
11534604fe | ||
|
|
9990952fa6 | ||
|
|
7ee7361646 | ||
|
|
89c6af516c | ||
|
|
c27f599e37 | ||
|
|
ef95aed208 | ||
|
|
8aaed0c76a | ||
|
|
6dbcb8cc47 | ||
|
|
6660fd099d | ||
|
|
e6ec5c5194 | ||
|
|
13e0eb7fcf | ||
|
|
7bdacb91ba | ||
|
|
94c985cb10 | ||
|
|
0f4ee33d2a | ||
|
|
4df1d5e0d4 | ||
|
|
085bbba302 | ||
|
|
ae2bb41509 | ||
|
|
9c03c1035b | ||
|
|
987993701f | ||
|
|
f8e110f180 | ||
|
|
2da8369c70 | ||
|
|
588c93467e | ||
|
|
01effb34b4 | ||
|
|
2c8dfc02c2 | ||
|
|
064707ac23 | ||
|
|
eaacaaba8d | ||
|
|
2cb43ff0b0 | ||
|
|
16081fbb19 | ||
|
|
4015bed474 | ||
|
|
c71c1d37c2 | ||
|
|
1776c3aa45 | ||
|
|
a1fc235327 | ||
|
|
2aea401c79 | ||
|
|
c83836ceed | ||
|
|
3efeffe6dd | ||
|
|
45a71fd1a3 | ||
|
|
0d139cee73 | ||
|
|
89f90ef849 | ||
|
|
7019124d1f | ||
|
|
0fd58c52ea | ||
|
|
8277b22c5c | ||
|
|
55ef9979c3 | ||
|
|
3d3b0f9e91 | ||
|
|
05375d9b11 | ||
|
|
66fdd91953 | ||
|
|
9fc9aeac08 | ||
|
|
935947f212 | ||
|
|
41f367a61b | ||
|
|
03cc95e755 | ||
|
|
b6fd7e2cf2 | ||
|
|
1526266bf3 | ||
|
|
e666073b18 | ||
|
|
2644aa9b7a | ||
|
|
701fe8ffc8 | ||
|
|
6117049489 | ||
|
|
6344038bac | ||
|
|
316ffe5a52 | ||
|
|
f7788a47bc | ||
|
|
a4afc39c13 | ||
|
|
b9b4d57b25 | ||
|
|
74a98d7eb3 | ||
|
|
b4618739f3 | ||
|
|
9b75db898a | ||
|
|
01f8578dea | ||
|
|
0a6bf221e6 | ||
|
|
4abe5659ae | ||
|
|
22c6c82c0e | ||
|
|
9cc286a1b0 | ||
|
|
19c875c18a | ||
|
|
12bbc0058c | ||
|
|
0696ff9f4b | ||
|
|
e735823d37 | ||
|
|
dbc88bc4ed | ||
|
|
d2f8acb68f | ||
|
|
8b70fe8bfd | ||
|
|
9228ac698d | ||
|
|
c0425193d0 | ||
|
|
28791b2118 | ||
|
|
e25f53d9a9 | ||
|
|
8f25a3bae8 | ||
|
|
3cdd646c37 | ||
|
|
3445eccc32 | ||
|
|
9c81806b95 | ||
|
|
45303005b8 | ||
|
|
656281eacb | ||
|
|
2b45fdbc8a | ||
|
|
22dcf6d69a | ||
|
|
60cce0d112 | ||
|
|
117433da8a | ||
|
|
55182e21b6 | ||
|
|
e76c6179fb | ||
|
|
e4f2c6c403 | ||
|
|
3c35770eca | ||
|
|
7c7ac1f1f6 | ||
|
|
da18111ed0 | ||
|
|
5e09cb9cab | ||
|
|
3662fbd038 | ||
|
|
281ef4714f | ||
|
|
26f99d983e | ||
|
|
9dd760223e | ||
|
|
9f1c9c9697 | ||
|
|
c81e509eb0 | ||
|
|
8b6d9b16c6 | ||
|
|
3341f4a45c | ||
|
|
b00f748647 | ||
|
|
f2bf3756db | ||
|
|
c98fa40c42 | ||
|
|
333c879192 | ||
|
|
e53d10fd85 | ||
|
|
5214a2aed2 | ||
|
|
12c2059812 | ||
|
|
a1b3fd3ffa | ||
|
|
6b39e58eb8 | ||
|
|
6d6f779d26 | ||
|
|
483fdf43dc | ||
|
|
e039e88f80 | ||
|
|
2459a0251a | ||
|
|
c7ee45aaca | ||
|
|
22217c793c | ||
|
|
fbeee69ece | ||
|
|
c79cfd21b0 | ||
|
|
deda3b433d | ||
|
|
906e813378 | ||
|
|
a9eeacb5be | ||
|
|
b4009cc0e6 | ||
|
|
942c3e5b70 | ||
|
|
2ec0be9635 | ||
|
|
d8d044f091 | ||
|
|
f968e583e8 | ||
|
|
88ba9c55d2 | ||
|
|
6baa594538 | ||
|
|
0e0fce714a | ||
|
|
cf24c7e8c2 | ||
|
|
ded6039b69 | ||
|
|
6b4327fa69 | ||
|
|
53e4669fc1 | ||
|
|
4dea10d5de | ||
|
|
bd2b39c27a | ||
|
|
e9864208ac | ||
|
|
f3b1d10d85 | ||
|
|
13aa1f5e5a | ||
|
|
aeca4865ae | ||
|
|
152f20027a | ||
|
|
4199439e07 | ||
|
|
64f55a572e | ||
|
|
a26554b4d6 | ||
|
|
0531a9e482 | ||
|
|
a5abb05ab3 | ||
|
|
45c0a4772d | ||
|
|
a36ff5ee6e | ||
|
|
78949e8ad5 | ||
|
|
06b7e14c31 | ||
|
|
41f1007936 | ||
|
|
092573fcbb | ||
|
|
881aa9adce | ||
|
|
4714be5697 | ||
|
|
cb7f4a72d7 | ||
|
|
fb63ff0db8 | ||
|
|
c5f447e20b | ||
|
|
b4d3769fd5 | ||
|
|
b199d597b2 | ||
|
|
debfb37a47 | ||
|
|
67fd6ffdf6 | ||
|
|
e57265016e | ||
|
|
cf4c22898c | ||
|
|
3bb99541e7 | ||
|
|
8c367af0ff | ||
|
|
e1c5e5e417 | ||
|
|
bc1e3edf17 | ||
|
|
e488f4a7da | ||
|
|
afad38d8e1 | ||
|
|
e9804d4184 | ||
|
|
a1b80a7abb | ||
|
|
803bfa7084 | ||
|
|
d1cb866b44 | ||
|
|
a298e0cf16 | ||
|
|
766b0125fb | ||
|
|
998fa43fb2 | ||
|
|
ecc6edac5a | ||
|
|
ea518ba4c9 | ||
|
|
49d5891bf5 | ||
|
|
5fd7ef5191 | ||
|
|
ffdf4222bf | ||
|
|
a97ef67411 | ||
|
|
aea4fea5a2 | ||
|
|
6c82e4d966 | ||
|
|
d1fbf0ba0a | ||
|
|
aaf26691d6 | ||
|
|
1f96c624e7 | ||
|
|
a7b08fefe4 | ||
|
|
91d4055545 | ||
|
|
523c320c08 | ||
|
|
27ae1c9f88 | ||
|
|
b03d0ae3b7 | ||
|
|
637ea97ffe | ||
|
|
074c271fb8 | ||
|
|
e348425204 | ||
|
|
b8f83610e7 | ||
|
|
41a445a989 | ||
|
|
80c7fff571 | ||
|
|
e2d98356ad | ||
|
|
a8412ea3d8 | ||
|
|
71177d8bf9 | ||
|
|
5019aac6b7 | ||
|
|
0c305f621a | ||
|
|
ef73bb27aa | ||
|
|
bd499c1ea2 | ||
|
|
24c4b10012 | ||
|
|
648cbb4999 | ||
|
|
ef5c86c427 | ||
|
|
5e0b80cada | ||
|
|
9898e8cd19 | ||
|
|
77d9b25bf0 | ||
|
|
e757e51a4e | ||
|
|
ce8711ba65 | ||
|
|
bdd7f69a66 | ||
|
|
d06ff7078a | ||
|
|
7416c8b7fc | ||
|
|
fc7cc4ca38 | ||
|
|
614e5ad4bb | ||
|
|
8d8ef6bbc9 | ||
|
|
5aed7b0abc | ||
|
|
856c1ffe0e | ||
|
|
650112a592 | ||
|
|
b5a37e35c6 | ||
|
|
91cf4589a5 | ||
|
|
4155775305 | ||
|
|
7c758501fc | ||
|
|
c70a39c568 | ||
|
|
2e88bd49d4 | ||
|
|
813219cd9c | ||
|
|
bb1f7d9294 | ||
|
|
6bffb66b5e | ||
|
|
72ab8695f1 | ||
|
|
47bdb84957 | ||
|
|
24cf4047b7 | ||
|
|
2e53963398 | ||
|
|
61842b199f | ||
|
|
aef64e5c29 | ||
|
|
6d13937c4a | ||
|
|
4b34a063e8 | ||
|
|
ba088d45a7 | ||
|
|
d12f9fd645 | ||
|
|
a6a3768a38 | ||
|
|
3d28c958d3 | ||
|
|
6b68916d74 | ||
|
|
8052b818de | ||
|
|
da4ed73ec6 | ||
|
|
62c9512734 | ||
|
|
d3a0ffc478 | ||
|
|
d84ad487ee | ||
|
|
01b80b300e | ||
|
|
66505f8f41 | ||
|
|
75378bb709 | ||
|
|
6fb6e707ba | ||
|
|
330473a092 | ||
|
|
5ee93b760a | ||
|
|
7911c2ebae | ||
|
|
3c00d66ccf | ||
|
|
12e752e681 | ||
|
|
1f342d0c71 | ||
|
|
5e86d4a48a | ||
|
|
0838f84859 | ||
|
|
abc938eacd | ||
|
|
4d13cd2c0b | ||
|
|
8f8971c706 | ||
|
|
2703d9513a | ||
|
|
3dce83de93 | ||
|
|
ef8e7d4251 | ||
|
|
a131e1ae73 | ||
|
|
84a86bd7bd | ||
|
|
adfb5886c9 | ||
|
|
8d237ea4ef | ||
|
|
513bc6eadf | ||
|
|
856962af63 | ||
|
|
128efe7fba | ||
|
|
7c68a29181 | ||
|
|
2f4e3f2bb1 | ||
|
|
0389b838bc | ||
|
|
441f53e382 | ||
|
|
83c45dcf40 | ||
|
|
cc9d81a39e | ||
|
|
edf14cfd84 | ||
|
|
ee79969eda | ||
|
|
dbb20b1fdc | ||
|
|
beee209249 | ||
|
|
d6876ce13b | ||
|
|
ed05d16a31 | ||
|
|
3806f01d2f | ||
|
|
5b69ced1e8 | ||
|
|
6b07f1d8b9 | ||
|
|
700cf14a50 | ||
|
|
1b08243cd2 | ||
|
|
a0b978004c | ||
|
|
efc8776e70 | ||
|
|
915f9a3782 | ||
|
|
e136d77ade | ||
|
|
04aec15833 |
@@ -1,7 +0,0 @@
|
|||||||
[bumpversion]
|
|
||||||
current_version = 1.7.2
|
|
||||||
commit = True
|
|
||||||
tag = True
|
|
||||||
|
|
||||||
[bumpversion:file:fbchat/__init__.py]
|
|
||||||
|
|
||||||
34
.github/ISSUE_TEMPLATE/bug_report.md
vendored
34
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -1,34 +0,0 @@
|
|||||||
---
|
|
||||||
name: Bug report
|
|
||||||
about: Create a report if you're having trouble with `fbchat`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Description of the problem
|
|
||||||
Example: Logging in fails when the character `%` is in the password. A specific password that fails is `a_password_with_%`
|
|
||||||
|
|
||||||
## Code to reproduce
|
|
||||||
```py
|
|
||||||
# Example code
|
|
||||||
from fbchat import Client
|
|
||||||
client = Client("[REDACTED_USERNAME]", "a_password_with_%")
|
|
||||||
```
|
|
||||||
|
|
||||||
## Traceback
|
|
||||||
```
|
|
||||||
Traceback (most recent call last):
|
|
||||||
File "<test.py>", line 1, in <module>
|
|
||||||
File "[site-packages]/fbchat/client.py", line 78, in __init__
|
|
||||||
self.login(email, password, max_tries)
|
|
||||||
File "[site-packages]/fbchat/client.py", line 407, in login
|
|
||||||
raise FBchatUserError('Login failed. Check email/password. (Failed on url: {})'.format(login_url))
|
|
||||||
fbchat.models.FBchatUserError: Login failed. Check email/password. (Failed on url: https://m.facebook.com/login.php?login_attempt=1)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Environment information
|
|
||||||
- Python version
|
|
||||||
- `fbchat` version
|
|
||||||
- If relevant, output from `$ python -m pip list`
|
|
||||||
|
|
||||||
If you have done any research, include that.
|
|
||||||
Make sure to redact all personal information.
|
|
||||||
19
.github/ISSUE_TEMPLATE/feature_request.md
vendored
19
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -1,19 +0,0 @@
|
|||||||
---
|
|
||||||
name: Feature request
|
|
||||||
about: Suggest a feature that you'd like to see implemented
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Description
|
|
||||||
Example: There's no way to send messages to groups
|
|
||||||
|
|
||||||
## Research (if applicable)
|
|
||||||
Example: I've found the URL `https://facebook.com/send_message.php`, to which you can send a POST requests with the following JSON:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"text": message_content,
|
|
||||||
"fbid": group_id,
|
|
||||||
"some_variable": ?
|
|
||||||
}
|
|
||||||
```
|
|
||||||
But I don't know how what `some_variable` does, and it doesn't work without it. I've found some examples of `some_variable` to be: `MTIzNDU2Nzg5MA`, `MTIzNDU2Nzg5MQ` and `MTIzNDU2Nzg5Mg`
|
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -33,6 +33,9 @@ my_data.json
|
|||||||
tests.data
|
tests.data
|
||||||
.pytest_cache
|
.pytest_cache
|
||||||
|
|
||||||
|
# MyPy
|
||||||
|
.mypy_cache/
|
||||||
|
|
||||||
# Virtual environment
|
# Virtual environment
|
||||||
venv/
|
venv/
|
||||||
.venv*/
|
.venv*/
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
# 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
|
|
||||||
61
.travis.yml
61
.travis.yml
@@ -1,61 +0,0 @@
|
|||||||
sudo: false
|
|
||||||
language: python
|
|
||||||
python: 3.6
|
|
||||||
|
|
||||||
cache: pip
|
|
||||||
|
|
||||||
before_install: pip install flit
|
|
||||||
# Use `--deps production` so that we don't install unnecessary dependencies
|
|
||||||
install: flit install --deps production --extras test
|
|
||||||
script: pytest -m offline
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
include:
|
|
||||||
- python: 2.7
|
|
||||||
before_install:
|
|
||||||
- sudo apt-get -y install python3-pip python3-setuptools
|
|
||||||
- sudo pip3 install flit
|
|
||||||
install: flit install --python python --deps production --extras test
|
|
||||||
- python: 3.4
|
|
||||||
- python: 3.5
|
|
||||||
- python: 3.6
|
|
||||||
- python: 3.7
|
|
||||||
dist: xenial
|
|
||||||
sudo: required
|
|
||||||
- python: pypy3.5
|
|
||||||
|
|
||||||
- name: Lint
|
|
||||||
before_install: skip
|
|
||||||
install: pip install black
|
|
||||||
script: black --check --verbose .
|
|
||||||
|
|
||||||
- stage: deploy
|
|
||||||
name: Github Releases
|
|
||||||
if: tag IS present
|
|
||||||
install: skip
|
|
||||||
script: flit build
|
|
||||||
deploy:
|
|
||||||
provider: releases
|
|
||||||
api_key: $GITHUB_OAUTH_TOKEN
|
|
||||||
file_glob: true
|
|
||||||
file: dist/*
|
|
||||||
skip_cleanup: true
|
|
||||||
draft: false
|
|
||||||
on:
|
|
||||||
tags: true
|
|
||||||
|
|
||||||
- stage: deploy
|
|
||||||
name: PyPI
|
|
||||||
if: tag IS present
|
|
||||||
install: skip
|
|
||||||
script: skip
|
|
||||||
deploy:
|
|
||||||
provider: script
|
|
||||||
script: flit publish
|
|
||||||
on:
|
|
||||||
tags: true
|
|
||||||
|
|
||||||
notifications:
|
|
||||||
email:
|
|
||||||
on_success: never
|
|
||||||
on_failure: change
|
|
||||||
4
.vscode/settings.json
vendored
Normal file
4
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"esbonio.sphinx.confDir": "",
|
||||||
|
"python.formatting.provider": "autopep8"
|
||||||
|
}
|
||||||
@@ -1,38 +1,42 @@
|
|||||||
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!
|
||||||
|
|
||||||
Please be aware that ``fbchat`` uses `Scemantic Versioning <https://semver.org/>`__
|
Please be aware that ``fbchat`` uses `Scemantic Versioning <https://semver.org/>`__ quite rigorously!
|
||||||
That means that if you're submitting a breaking change, it will probably take a while before it gets considered.
|
That means that if you're submitting a breaking change, it will probably take a while before it gets considered.
|
||||||
|
|
||||||
In that case, you can point your PR to the ``2.0.0-dev`` branch, where the API is being properly developed.
|
|
||||||
Otherwise, just point it to ``master``.
|
|
||||||
|
|
||||||
Development Environment
|
Development Environment
|
||||||
-----------------------
|
-----------------------
|
||||||
|
|
||||||
You can use `flit` to install the package as a symlink:
|
This project uses ``flit`` to configure development environments. You can install it using:
|
||||||
|
|
||||||
.. code-block::
|
.. code-block:: sh
|
||||||
|
|
||||||
|
$ pip install flit
|
||||||
|
|
||||||
|
And now you can install ``fbchat`` as a symlink:
|
||||||
|
|
||||||
|
.. code-block:: sh
|
||||||
|
|
||||||
|
$ git clone https://github.com/carpedm20/fbchat.git
|
||||||
|
$ cd fbchat
|
||||||
$ # *nix:
|
$ # *nix:
|
||||||
$ flit install --symlink
|
$ flit install --symlink
|
||||||
$ # Windows:
|
$ # Windows:
|
||||||
$ flit install --pth-file
|
$ flit install --pth-file
|
||||||
|
|
||||||
|
This will also install required development tools like ``black``, ``pytest`` and ``sphinx``.
|
||||||
|
|
||||||
After that, you can ``import`` the module as normal.
|
After that, you can ``import`` the module as normal.
|
||||||
|
|
||||||
Before committing, you should run ``black .`` in the main directory, to format your code.
|
Checklist
|
||||||
|
---------
|
||||||
|
|
||||||
Testing Environment
|
Once you're done with your work, please follow the steps below:
|
||||||
-------------------
|
|
||||||
|
|
||||||
The tests use `pytest <https://docs.pytest.org/>`__, and to work they need two Facebook accounts, and a group thread between these.
|
- Run ``black .`` to format your code.
|
||||||
To set these up, you should export the following environment variables:
|
- Run ``pytest`` to test your code.
|
||||||
|
- Run ``make -C docs html``, and view the generated docs, to verify that the docs still work.
|
||||||
``client1_email``, ``client1_password``, ``client2_email``, ``client2_password`` and ``group_id``
|
- Run ``make -C docs spelling`` to check your spelling in docstrings.
|
||||||
|
- Create a pull request, and point it to ``master`` `here <https://github.com/carpedm20/fbchat/pulls/new>`__.
|
||||||
If you're not able to do this, consider simply running ``pytest -m offline``.
|
|
||||||
|
|
||||||
And if you're adding new functionality, if possible, make sure to create a new test for it.
|
|
||||||
|
|||||||
67
README.rst
67
README.rst
@@ -1,50 +1,47 @@
|
|||||||
fbchat: Facebook Chat (Messenger) for Python
|
``fbchat`` - Facebook Messenger for Python
|
||||||
============================================
|
==========================================
|
||||||
|
|
||||||
.. image:: https://img.shields.io/badge/license-BSD-blue.svg
|
A powerful and efficient library to interact with
|
||||||
:target: https://github.com/carpedm20/fbchat/tree/master/LICENSE
|
`Facebook's Messenger <https://www.facebook.com/messages/>`__, using just your email and password.
|
||||||
:alt: License: BSD 3-Clause
|
|
||||||
|
|
||||||
.. image:: https://img.shields.io/badge/python-2.7%2C%203.4%2C%203.5%2C%203.6%203.7%20pypy-blue.svg
|
This is *not* an official API, Facebook has that `over here <https://developers.facebook.com/docs/messenger-platform>`__ for chat bots. This library differs by using a normal Facebook account instead.
|
||||||
:target: https://pypi.python.org/pypi/fbchat
|
|
||||||
: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
|
``fbchat`` currently support:
|
||||||
:target: https://fbchat.readthedocs.io
|
|
||||||
:alt: Documentation
|
|
||||||
|
|
||||||
.. image:: https://travis-ci.org/carpedm20/fbchat.svg?branch=master
|
- Sending many types of messages, with files, stickers, mentions, etc.
|
||||||
:target: https://travis-ci.org/carpedm20/fbchat
|
- Fetching all messages, threads and images in threads.
|
||||||
:alt: Travis CI
|
- Searching for messages and threads.
|
||||||
|
- Creating groups, setting the group emoji, changing nicknames, creating polls, etc.
|
||||||
|
- Listening for, an reacting to messages and other events in real-time.
|
||||||
|
- Type hints, and it has a modern codebase (e.g. only Python 3.5 and upwards).
|
||||||
|
|
||||||
.. image:: https://img.shields.io/badge/code%20style-black-000000.svg
|
Essentially, everything you need to make an amazing Facebook bot!
|
||||||
:target: https://github.com/ambv/black
|
|
||||||
:alt: Code style
|
|
||||||
|
|
||||||
Facebook Chat (`Messenger <https://www.facebook.com/messages/>`__) for Python.
|
|
||||||
This project was inspired by `facebook-chat-api <https://github.com/Schmavery/facebook-chat-api>`__.
|
|
||||||
|
|
||||||
**No XMPP or API key is needed**. Just use your email and password.
|
Version Warning
|
||||||
|
---------------
|
||||||
|
``v2`` is currently being developed at the ``master`` branch and it's highly unstable.
|
||||||
|
|
||||||
Go to `Read the Docs <https://fbchat.readthedocs.io>`__ to see the full documentation,
|
|
||||||
or jump right into the code by viewing the `examples <https://github.com/carpedm20/fbchat/tree/master/examples>`__
|
|
||||||
|
|
||||||
Installation:
|
Caveats
|
||||||
|
-------
|
||||||
|
|
||||||
|
``fbchat`` works by imitating what the browser does, and thereby tricking Facebook into thinking it's accessing the website normally.
|
||||||
|
|
||||||
|
However, there's a catch! **Using this library may not comply with Facebook's Terms Of Service!**, so be responsible Facebook citizens! We are not responsible if your account gets banned!
|
||||||
|
|
||||||
|
Additionally, **the APIs the library is calling is undocumented!** In theory, this means that your code could break tomorrow, without the slightest warning!
|
||||||
|
|
||||||
|
|
||||||
|
Installation
|
||||||
|
------------
|
||||||
|
|
||||||
.. code-block::
|
.. code-block::
|
||||||
|
|
||||||
$ pip install fbchat
|
$ pip install git+https://git.karaolidis.com/karaolidis/fbchat.git
|
||||||
|
|
||||||
You can also install from source if you have ``pip>=19.0``:
|
|
||||||
|
|
||||||
.. code-block::
|
|
||||||
|
|
||||||
$ git clone https://github.com/carpedm20/fbchat.git
|
|
||||||
$ pip install fbchat
|
|
||||||
|
|
||||||
|
|
||||||
Maintainer
|
Acknowledgements
|
||||||
----------
|
----------------
|
||||||
|
|
||||||
- Mads Marquart / `@madsmtm <https://github.com/madsmtm>`__
|
This project is a fork of `fbchat <https://github.com/fbchat-dev/fbchat>`__ and was originally inspired by `facebook-chat-api <https://github.com/Schmavery/facebook-chat-api>`__.
|
||||||
- Taehoon Kim / `@carpedm20 <http://carpedm20.github.io/about/>`__
|
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
# Minimal makefile for Sphinx documentation
|
|
||||||
#
|
|
||||||
|
|
||||||
# You can set these variables from the command line.
|
|
||||||
SPHINXOPTS =
|
|
||||||
SPHINXBUILD = sphinx-build
|
|
||||||
SOURCEDIR = .
|
|
||||||
BUILDDIR = _build
|
|
||||||
|
|
||||||
# Put it first so that "make" without argument is like "make help".
|
|
||||||
help:
|
|
||||||
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
|
|
||||||
|
|
||||||
.PHONY: help Makefile
|
|
||||||
|
|
||||||
# Catch-all target: route all unknown targets to Sphinx using the new
|
|
||||||
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
|
|
||||||
%: Makefile
|
|
||||||
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
|
|
||||||
BIN
docs/_static/find-group-id.png
vendored
BIN
docs/_static/find-group-id.png
vendored
Binary file not shown.
|
Before Width: | Height: | Size: 54 KiB |
1
docs/_static/license.svg
vendored
1
docs/_static/license.svg
vendored
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="80" height="20"><linearGradient id="b" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="a"><rect width="80" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#a)"><path fill="#555" d="M0 0h47v20H0z"/><path fill="#007ec6" d="M47 0h33v20H47z"/><path fill="url(#b)" d="M0 0h80v20H0z"/></g><g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11"><text x="23.5" y="15" fill="#010101" fill-opacity=".3">license</text><text x="23.5" y="14">license</text><text x="62.5" y="15" fill="#010101" fill-opacity=".3">BSD</text><text x="62.5" y="14">BSD</text></g></svg>
|
|
||||||
|
Before Width: | Height: | Size: 791 B |
1
docs/_static/python-versions.svg
vendored
1
docs/_static/python-versions.svg
vendored
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="154" height="20"><linearGradient id="b" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="a"><rect width="154" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#a)"><path fill="#555" d="M0 0h49v20H0z"/><path fill="#007ec6" d="M49 0h105v20H49z"/><path fill="url(#b)" d="M0 0h154v20H0z"/></g><g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11"><text x="24.5" y="15" fill="#010101" fill-opacity=".3">python</text><text x="24.5" y="14">python</text><text x="100.5" y="15" fill="#010101" fill-opacity=".3">2.7, 3.4, 3.5, 3.6</text><text x="100.5" y="14">2.7, 3.4, 3.5, 3.6</text></g></svg>
|
|
||||||
|
Before Width: | Height: | Size: 825 B |
26
docs/_templates/layout.html
vendored
26
docs/_templates/layout.html
vendored
@@ -1,26 +0,0 @@
|
|||||||
{% extends '!layout.html' %}
|
|
||||||
|
|
||||||
{% block extrahead %}
|
|
||||||
<script async defer src="https://buttons.github.io/buttons.js"></script>
|
|
||||||
<!-- Alabaster (krTheme++) Hacks, modified version of Kenneth Reitz' https://github.com/kennethreitz/requests/blob/master/docs/_templates/hacks.html -->
|
|
||||||
<style type="text/css">
|
|
||||||
/* Rezzy requires precise alignment. */
|
|
||||||
img.logo {margin-left: -20px!important;}
|
|
||||||
/* "Quick Search" should be capitalized. */
|
|
||||||
div#searchbox h3 {text-transform: capitalize;}
|
|
||||||
/* Go button should be behind input field */
|
|
||||||
div.sphinxsidebar div#searchbox input[type="text"] {width: 160px}
|
|
||||||
div#searchbox form div {display: inline-block;}
|
|
||||||
/* Make the document a little wider, less code is cut-off. */
|
|
||||||
div.document {width: 1008px;}
|
|
||||||
/* Much-improved spacing around code blocks. */
|
|
||||||
div.highlight pre {padding: 11px 14px;}
|
|
||||||
/* Remain Responsive! */
|
|
||||||
@media screen and (max-width: 1008px) {
|
|
||||||
div.sphinxsidebar {display: none;}
|
|
||||||
div.document {width: 100%!important;}
|
|
||||||
/* Have code blocks escape the document right-margin. */
|
|
||||||
div.highlight pre {margin-right: -30px;}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
{% endblock %}
|
|
||||||
13
docs/_templates/sidebar.html
vendored
13
docs/_templates/sidebar.html
vendored
@@ -1,13 +0,0 @@
|
|||||||
<h3>
|
|
||||||
<a href="{{ pathto(master_doc) }}">{{ _(project) }}</a>
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
<a class="github-button" href="https://github.com/carpedm20/fbchat" data-size="large" data-show-count="true" aria-label="Star carpedm20/fbchat on GitHub">Star</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
{{ _(shorttitle) }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{{ toctree() }}
|
|
||||||
79
docs/api.rst
79
docs/api.rst
@@ -1,79 +0,0 @@
|
|||||||
.. module:: fbchat
|
|
||||||
.. _api:
|
|
||||||
|
|
||||||
.. Note: we're using () to hide the __init__ method where relevant
|
|
||||||
|
|
||||||
Full API
|
|
||||||
========
|
|
||||||
|
|
||||||
If you are looking for information on a specific function, class, or method, this part of the documentation is for you.
|
|
||||||
|
|
||||||
Client
|
|
||||||
------
|
|
||||||
|
|
||||||
.. autoclass:: Client
|
|
||||||
|
|
||||||
Threads
|
|
||||||
-------
|
|
||||||
|
|
||||||
.. autoclass:: Thread()
|
|
||||||
.. autoclass:: ThreadType(Enum)
|
|
||||||
:undoc-members:
|
|
||||||
.. autoclass:: Page()
|
|
||||||
.. autoclass:: User()
|
|
||||||
.. autoclass:: Group()
|
|
||||||
|
|
||||||
Messages
|
|
||||||
--------
|
|
||||||
|
|
||||||
.. 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:
|
|
||||||
184
docs/conf.py
184
docs/conf.py
@@ -1,184 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
#
|
|
||||||
# Configuration file for the Sphinx documentation builder.
|
|
||||||
#
|
|
||||||
# This file does only contain a selection of the most common options. For a
|
|
||||||
# full list see the documentation:
|
|
||||||
# http://www.sphinx-doc.org/en/master/config
|
|
||||||
|
|
||||||
# -- Path setup --------------------------------------------------------------
|
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
sys.path.insert(0, os.path.abspath(".."))
|
|
||||||
|
|
||||||
import fbchat
|
|
||||||
|
|
||||||
# -- 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 ---------------------------------------------------
|
|
||||||
|
|
||||||
# If your documentation needs a minimal Sphinx version, state it here.
|
|
||||||
#
|
|
||||||
needs_sphinx = "2.0"
|
|
||||||
|
|
||||||
# Add any Sphinx extension module names here, as strings. They can be
|
|
||||||
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
|
|
||||||
# ones.
|
|
||||||
extensions = [
|
|
||||||
"sphinx.ext.autodoc",
|
|
||||||
"sphinx.ext.intersphinx",
|
|
||||||
"sphinx.ext.todo",
|
|
||||||
"sphinx.ext.viewcode",
|
|
||||||
]
|
|
||||||
|
|
||||||
# Add any paths that contain templates here, relative to this directory.
|
|
||||||
templates_path = ["_templates"]
|
|
||||||
|
|
||||||
# The master toctree document.
|
|
||||||
master_doc = "index"
|
|
||||||
|
|
||||||
# List of patterns, relative to source directory, that match files and
|
|
||||||
# directories to ignore when looking for source files.
|
|
||||||
# This pattern also affects html_static_path and html_extra_path.
|
|
||||||
exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
|
|
||||||
|
|
||||||
rst_prolog = ".. currentmodule:: " + project
|
|
||||||
|
|
||||||
# The reST default role (used for this markup: `text`) to use for all
|
|
||||||
# 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 -------------------------------------------------
|
|
||||||
|
|
||||||
# The theme to use for HTML and HTML Help pages. See the documentation for
|
|
||||||
# a list of builtin themes.
|
|
||||||
#
|
|
||||||
html_theme = "alabaster"
|
|
||||||
|
|
||||||
# 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
|
|
||||||
# documentation.
|
|
||||||
#
|
|
||||||
html_theme_options = {
|
|
||||||
"show_powered_by": False,
|
|
||||||
"github_user": "carpedm20",
|
|
||||||
"github_repo": project,
|
|
||||||
"github_banner": True,
|
|
||||||
"show_related": False,
|
|
||||||
}
|
|
||||||
|
|
||||||
html_extra_path = ["robots.txt"]
|
|
||||||
|
|
||||||
# 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"]}
|
|
||||||
|
|
||||||
# If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
|
|
||||||
#
|
|
||||||
html_show_sphinx = False
|
|
||||||
|
|
||||||
# If true, links to the reST sources are added to the pages.
|
|
||||||
#
|
|
||||||
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"
|
|
||||||
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
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
.. _examples:
|
|
||||||
|
|
||||||
Examples
|
|
||||||
========
|
|
||||||
|
|
||||||
These are a few examples on how to use ``fbchat``. Remember to swap out ``<email>`` and ``<password>`` for your email and password
|
|
||||||
|
|
||||||
|
|
||||||
Basic example
|
|
||||||
-------------
|
|
||||||
|
|
||||||
This will show basic usage of ``fbchat``
|
|
||||||
|
|
||||||
.. literalinclude:: ../examples/basic_usage.py
|
|
||||||
|
|
||||||
|
|
||||||
Interacting with Threads
|
|
||||||
------------------------
|
|
||||||
|
|
||||||
This will interact with the thread in every way ``fbchat`` supports
|
|
||||||
|
|
||||||
.. literalinclude:: ../examples/interract.py
|
|
||||||
|
|
||||||
|
|
||||||
Fetching Information
|
|
||||||
--------------------
|
|
||||||
|
|
||||||
This will show the different ways of fetching information about users and threads
|
|
||||||
|
|
||||||
.. literalinclude:: ../examples/fetch.py
|
|
||||||
|
|
||||||
|
|
||||||
Echobot
|
|
||||||
-------
|
|
||||||
|
|
||||||
This will reply to any message with the same message
|
|
||||||
|
|
||||||
.. literalinclude:: ../examples/echobot.py
|
|
||||||
|
|
||||||
|
|
||||||
Remove Bot
|
|
||||||
----------
|
|
||||||
|
|
||||||
This will remove a user from a group if they write the message ``Remove me!``
|
|
||||||
|
|
||||||
.. literalinclude:: ../examples/removebot.py
|
|
||||||
|
|
||||||
|
|
||||||
"Prevent changes"-Bot
|
|
||||||
---------------------
|
|
||||||
|
|
||||||
This will prevent chat color, emoji, nicknames and chat name from being changed.
|
|
||||||
It will also prevent people from being added and removed
|
|
||||||
|
|
||||||
.. literalinclude:: ../examples/keepbot.py
|
|
||||||
42
docs/faq.rst
42
docs/faq.rst
@@ -1,42 +0,0 @@
|
|||||||
.. _faq:
|
|
||||||
|
|
||||||
FAQ
|
|
||||||
===
|
|
||||||
|
|
||||||
Version X broke my installation
|
|
||||||
-------------------------------
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
Downgrade to an earlier version of fbchat, run this command
|
|
||||||
|
|
||||||
.. code-block:: sh
|
|
||||||
|
|
||||||
$ pip install fbchat==<X>
|
|
||||||
|
|
||||||
Where you replace ``<X>`` with the version you want to use
|
|
||||||
|
|
||||||
|
|
||||||
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 ;)
|
|
||||||
|
|
||||||
|
|
||||||
Submitting Issues
|
|
||||||
-----------------
|
|
||||||
|
|
||||||
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>`_.
|
|
||||||
You should first login with ``logging_level`` set to ``logging.DEBUG``::
|
|
||||||
|
|
||||||
from fbchat import Client
|
|
||||||
import logging
|
|
||||||
client = Client('<email>', '<password>', logging_level=logging.DEBUG)
|
|
||||||
|
|
||||||
Then you can submit the relevant parts of this log, and detailed steps on how to reproduce
|
|
||||||
|
|
||||||
.. warning::
|
|
||||||
Always remove your credentials from any debug information you may provide us.
|
|
||||||
Preferably, use a test account, in case you miss anything
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
.. fbchat documentation master file, created by
|
|
||||||
sphinx-quickstart on Thu May 25 15:43:01 2017.
|
|
||||||
You can adapt this file completely to your liking, but it should at least
|
|
||||||
contain the root `toctree` directive.
|
|
||||||
|
|
||||||
.. 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
|
|
||||||
|
|
||||||
fbchat: Facebook Chat (Messenger) for Python
|
|
||||||
============================================
|
|
||||||
|
|
||||||
Release v\ |version|. (:ref:`install`)
|
|
||||||
|
|
||||||
.. generated with: https://img.shields.io/badge/license-BSD-blue.svg
|
|
||||||
|
|
||||||
.. image:: /_static/license.svg
|
|
||||||
:target: https://github.com/carpedm20/fbchat/blob/master/LICENSE.txt
|
|
||||||
:alt: License: BSD
|
|
||||||
|
|
||||||
.. generated with: https://img.shields.io/badge/python-2.7%2C%203.4%2C%203.5%2C%203.6-blue.svg
|
|
||||||
|
|
||||||
.. image:: /_static/python-versions.svg
|
|
||||||
:target: https://pypi.python.org/pypi/fbchat
|
|
||||||
:alt: Supported python versions: 2.7, 3.4, 3.5 and 3.6
|
|
||||||
|
|
||||||
Facebook Chat (`Messenger <https://www.facebook.com/messages/>`_) for Python.
|
|
||||||
This project was inspired by `facebook-chat-api <https://github.com/Schmavery/facebook-chat-api>`_.
|
|
||||||
|
|
||||||
**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:
|
|
||||||
|
|
||||||
``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.
|
|
||||||
Therefore, this API requires the credentials of a Facebook account.
|
|
||||||
|
|
||||||
.. note::
|
|
||||||
If you're having problems, please check the :ref:`faq`, before asking questions on Github
|
|
||||||
|
|
||||||
.. warning::
|
|
||||||
We are not responsible if your account gets banned for spammy activities,
|
|
||||||
such as sending lots of messages to people you don't know, sending messages very quickly,
|
|
||||||
sending spammy looking URLs, logging in and out very quickly... Be responsible Facebook citizens.
|
|
||||||
|
|
||||||
.. note::
|
|
||||||
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.
|
|
||||||
|
|
||||||
If you're already familiar with the basics of how Facebook works internally, go to :ref:`examples` to see example usage of ``fbchat``
|
|
||||||
|
|
||||||
|
|
||||||
Overview
|
|
||||||
--------
|
|
||||||
|
|
||||||
.. toctree::
|
|
||||||
:maxdepth: 2
|
|
||||||
|
|
||||||
install
|
|
||||||
intro
|
|
||||||
examples
|
|
||||||
testing
|
|
||||||
api
|
|
||||||
todo
|
|
||||||
faq
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
.. _install:
|
|
||||||
|
|
||||||
Installation
|
|
||||||
============
|
|
||||||
|
|
||||||
Pip Install fbchat
|
|
||||||
------------------
|
|
||||||
|
|
||||||
To install fbchat, run this command:
|
|
||||||
|
|
||||||
.. code-block:: sh
|
|
||||||
|
|
||||||
$ pip install fbchat
|
|
||||||
|
|
||||||
If you don't have `pip <https://pip.pypa.io>`_ installed,
|
|
||||||
`this Python installation guide <http://docs.python-guide.org/en/latest/starting/installation/>`_
|
|
||||||
can guide you through the process.
|
|
||||||
|
|
||||||
Get the Source Code
|
|
||||||
-------------------
|
|
||||||
|
|
||||||
fbchat is developed on GitHub, where the code is
|
|
||||||
`always available <https://github.com/carpedm20/fbchat>`_.
|
|
||||||
|
|
||||||
You can either clone the public repository:
|
|
||||||
|
|
||||||
.. code-block:: sh
|
|
||||||
|
|
||||||
$ git clone git://github.com/carpedm20/fbchat.git
|
|
||||||
|
|
||||||
Or, download a `tarball <https://github.com/carpedm20/fbchat/tarball/master>`_:
|
|
||||||
|
|
||||||
.. code-block:: sh
|
|
||||||
|
|
||||||
$ curl -OL https://github.com/carpedm20/fbchat/tarball/master
|
|
||||||
# optionally, zipball is also available (for Windows users).
|
|
||||||
|
|
||||||
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:
|
|
||||||
|
|
||||||
.. code-block:: sh
|
|
||||||
|
|
||||||
$ python setup.py install
|
|
||||||
198
docs/intro.rst
198
docs/intro.rst
@@ -1,198 +0,0 @@
|
|||||||
.. _intro:
|
|
||||||
|
|
||||||
Introduction
|
|
||||||
============
|
|
||||||
|
|
||||||
``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.
|
|
||||||
You should also make sure that the file's access control is appropriately restrictive
|
|
||||||
|
|
||||||
|
|
||||||
.. _intro_logging_in:
|
|
||||||
|
|
||||||
Logging In
|
|
||||||
----------
|
|
||||||
|
|
||||||
Simply create an instance of :class:`Client`. If you have two factor authentication enabled, type the code in the terminal prompt
|
|
||||||
(If you want to supply the code in another fashion, overwrite :func:`Client.on2FACode`)::
|
|
||||||
|
|
||||||
from fbchat import Client
|
|
||||||
from fbchat.models import *
|
|
||||||
client = Client('<email>', '<password>')
|
|
||||||
|
|
||||||
Replace ``<email>`` and ``<password>`` with your email and password respectively
|
|
||||||
|
|
||||||
.. note::
|
|
||||||
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
|
|
||||||
|
|
||||||
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`.
|
|
||||||
An example would be to login again if you've been logged out, using :func:`Client.login`::
|
|
||||||
|
|
||||||
if not client.isLoggedIn():
|
|
||||||
client.login('<email>', '<password>')
|
|
||||||
|
|
||||||
When you're done using the client, and want to securely logout, use :func:`Client.logout`::
|
|
||||||
|
|
||||||
client.logout()
|
|
||||||
|
|
||||||
|
|
||||||
.. _intro_threads:
|
|
||||||
|
|
||||||
Threads
|
|
||||||
-------
|
|
||||||
|
|
||||||
A thread can refer to two things: A Messenger group chat or a single Facebook user
|
|
||||||
|
|
||||||
: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.
|
|
||||||
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`,
|
|
||||||
and searching for users is possible via. :func:`Client.searchForUsers`. See :ref:`intro_fetching`
|
|
||||||
|
|
||||||
You can get your own user ID by using :any:`Client.uid`
|
|
||||||
|
|
||||||
Getting the ID of a group chat is fairly trivial otherwise, since you only need to navigate to `<https://www.facebook.com/messages/>`_,
|
|
||||||
click on the group you want to find the ID of, and then read the id from the address bar.
|
|
||||||
The URL will look something like this: ``https://www.facebook.com/messages/t/1234567890``, where ``1234567890`` would be the ID of the group.
|
|
||||||
An image to illustrate this is shown below:
|
|
||||||
|
|
||||||
.. image:: /_static/find-group-id.png
|
|
||||||
:alt: An image illustrating how to find the ID of a group
|
|
||||||
|
|
||||||
The same method can be applied to some user accounts, though if they've set a custom URL, then you'll just see that URL instead
|
|
||||||
|
|
||||||
Here's an snippet showing the usage of thread IDs and thread types, where ``<user id>`` and ``<group id>``
|
|
||||||
corresponds to the ID of a single user, and the ID of a group respectively::
|
|
||||||
|
|
||||||
client.send(Message(text='<message>'), thread_id='<user id>', thread_type=ThreadType.USER)
|
|
||||||
client.send(Message(text='<message>'), thread_id='<group id>', thread_type=ThreadType.GROUP)
|
|
||||||
|
|
||||||
Some functions (e.g. :func:`Client.changeThreadColor`) don't require a thread type, so in these cases you just provide the thread ID::
|
|
||||||
|
|
||||||
client.changeThreadColor(ThreadColor.BILOBA_FLOWER, thread_id='<user id>')
|
|
||||||
client.changeThreadColor(ThreadColor.MESSENGER_BLUE, thread_id='<group id>')
|
|
||||||
|
|
||||||
|
|
||||||
.. _intro_message_ids:
|
|
||||||
|
|
||||||
Message IDs
|
|
||||||
-----------
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
Some of ``fbchat``'s functions require these ID's, like :func:`Client.reactToMessage`,
|
|
||||||
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::
|
|
||||||
|
|
||||||
message_id = client.send(Message(text='message'), thread_id=thread_id, thread_type=thread_type)
|
|
||||||
client.reactToMessage(message_id, MessageReaction.LOVE)
|
|
||||||
|
|
||||||
|
|
||||||
.. _intro_interacting:
|
|
||||||
|
|
||||||
Interacting with Threads
|
|
||||||
------------------------
|
|
||||||
|
|
||||||
``fbchat`` provides multiple functions for interacting with threads
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
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::
|
|
||||||
|
|
||||||
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`
|
|
||||||
|
|
||||||
|
|
||||||
.. _intro_fetching:
|
|
||||||
|
|
||||||
Fetching Information
|
|
||||||
--------------------
|
|
||||||
|
|
||||||
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`.
|
|
||||||
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::
|
|
||||||
|
|
||||||
users = client.searchForUsers('<name of user>')
|
|
||||||
user = users[0]
|
|
||||||
print("User's ID: {}".format(user.uid))
|
|
||||||
print("User's name: {}".format(user.name))
|
|
||||||
print("User's profile picture url: {}".format(user.photo))
|
|
||||||
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
|
|
||||||
|
|
||||||
You can see a full example showing all the possible ways to fetch information with ``fbchat`` by going to :ref:`examples`
|
|
||||||
|
|
||||||
|
|
||||||
.. _intro_sessions:
|
|
||||||
|
|
||||||
Sessions
|
|
||||||
--------
|
|
||||||
|
|
||||||
``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.
|
|
||||||
Use :func:`Client.getSession` to retrieve the cookies::
|
|
||||||
|
|
||||||
session_cookies = client.getSession()
|
|
||||||
|
|
||||||
Then you can use :func:`Client.setSession`::
|
|
||||||
|
|
||||||
client.setSession(session_cookies)
|
|
||||||
|
|
||||||
Or you can set the ``session_cookies`` on your initial login.
|
|
||||||
(If the session cookies are invalid, your email and password will be used to login instead)::
|
|
||||||
|
|
||||||
client = Client('<email>', '<password>', session_cookies=session_cookies)
|
|
||||||
|
|
||||||
.. warning::
|
|
||||||
You session cookies can be just as valueable as you password, so store them with equal care
|
|
||||||
|
|
||||||
|
|
||||||
.. _intro_events:
|
|
||||||
|
|
||||||
Listening & Events
|
|
||||||
------------------
|
|
||||||
|
|
||||||
To use the listening functions ``fbchat`` offers (like :func:`Client.listen`),
|
|
||||||
you have to define what should be executed when certain events happen.
|
|
||||||
By default, (most) events will just be a `logging.info` statement,
|
|
||||||
meaning it will simply print information to the console when an event happens
|
|
||||||
|
|
||||||
.. note::
|
|
||||||
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::
|
|
||||||
|
|
||||||
class CustomClient(Client):
|
|
||||||
def onMessage(self, mid, author_id, message_object, thread_id, thread_type, ts, metadata, msg, **kwargs):
|
|
||||||
# Do something with message_object here
|
|
||||||
pass
|
|
||||||
|
|
||||||
client = CustomClient('<email>', '<password>')
|
|
||||||
|
|
||||||
**Notice:** The following snippet is as equally valid as the previous one::
|
|
||||||
|
|
||||||
class CustomClient(Client):
|
|
||||||
def onMessage(self, message_object, author_id, thread_id, thread_type, **kwargs):
|
|
||||||
# Do something with message_object here
|
|
||||||
pass
|
|
||||||
|
|
||||||
client = CustomClient('<email>', '<password>')
|
|
||||||
|
|
||||||
The change was in the parameters that our `onMessage` method took: ``message_object`` and ``author_id`` got swapped,
|
|
||||||
and ``mid``, ``ts``, ``metadata`` and ``msg`` got removed, but the function still works, since we included ``**kwargs``
|
|
||||||
|
|
||||||
.. note::
|
|
||||||
Therefore, for both backwards and forwards compatability,
|
|
||||||
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
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
@ECHO OFF
|
|
||||||
|
|
||||||
pushd %~dp0
|
|
||||||
|
|
||||||
REM Command file for Sphinx documentation
|
|
||||||
|
|
||||||
if "%SPHINXBUILD%" == "" (
|
|
||||||
set SPHINXBUILD=sphinx-build
|
|
||||||
)
|
|
||||||
set SOURCEDIR=.
|
|
||||||
set BUILDDIR=_build
|
|
||||||
|
|
||||||
if "%1" == "" goto help
|
|
||||||
|
|
||||||
%SPHINXBUILD% >NUL 2>NUL
|
|
||||||
if errorlevel 9009 (
|
|
||||||
echo.
|
|
||||||
echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
|
|
||||||
echo.installed, then set the SPHINXBUILD environment variable to point
|
|
||||||
echo.to the full path of the 'sphinx-build' executable. Alternatively you
|
|
||||||
echo.may add the Sphinx directory to PATH.
|
|
||||||
echo.
|
|
||||||
echo.If you don't have Sphinx installed, grab it from
|
|
||||||
echo.http://sphinx-doc.org/
|
|
||||||
exit /b 1
|
|
||||||
)
|
|
||||||
|
|
||||||
%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS%
|
|
||||||
goto end
|
|
||||||
|
|
||||||
:help
|
|
||||||
%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS%
|
|
||||||
|
|
||||||
:end
|
|
||||||
popd
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
User-agent: *
|
|
||||||
Disallow: /en/master/
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
.. _testing:
|
|
||||||
|
|
||||||
Testing
|
|
||||||
=======
|
|
||||||
|
|
||||||
To use the tests, copy ``tests/data.json`` to ``tests/my_data.json`` or type the information manually in the terminal prompts.
|
|
||||||
|
|
||||||
- email: Your (or a test user's) email / phone number
|
|
||||||
- password: Your (or a test user's) password
|
|
||||||
- group_thread_id: A test group that will be used to test group functionality
|
|
||||||
- user_thread_id: A person that will be used to test kick/add functionality (This user should be in the group)
|
|
||||||
|
|
||||||
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 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
|
|
||||||
|
|
||||||
.. warning::
|
|
||||||
|
|
||||||
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)
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
.. _todo:
|
|
||||||
|
|
||||||
Todo
|
|
||||||
====
|
|
||||||
|
|
||||||
This page will be periodically updated to show missing features and documentation
|
|
||||||
|
|
||||||
|
|
||||||
Missing Functionality
|
|
||||||
---------------------
|
|
||||||
|
|
||||||
- Implement Client.searchForMessage
|
|
||||||
- This will use the graphql request API
|
|
||||||
- Implement chatting with pages properly
|
|
||||||
- Write better FAQ
|
|
||||||
- Explain usage of graphql
|
|
||||||
|
|
||||||
|
|
||||||
Documentation
|
|
||||||
-------------
|
|
||||||
|
|
||||||
.. todolist::
|
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
# -*- coding: UTF-8 -*-
|
import fbchat
|
||||||
|
|
||||||
from fbchat import Client
|
# Log the user in
|
||||||
from fbchat.models import *
|
session = fbchat.Session.login("<email>", "<password>")
|
||||||
|
|
||||||
client = Client("<email>", "<password>")
|
print("Own id: {}".format(session.user.id))
|
||||||
|
|
||||||
print("Own id: {}".format(client.uid))
|
# Send a message to yourself
|
||||||
|
session.user.send_text("Hi me!")
|
||||||
|
|
||||||
client.send(Message(text="Hi me!"), thread_id=client.uid, thread_type=ThreadType.USER)
|
# Log the user out
|
||||||
|
session.logout()
|
||||||
client.logout()
|
|
||||||
|
|||||||
@@ -1,19 +1,11 @@
|
|||||||
# -*- coding: UTF-8 -*-
|
import fbchat
|
||||||
|
|
||||||
from fbchat import log, Client
|
session = fbchat.Session.login("<email>", "<password>")
|
||||||
|
listener = fbchat.Listener(session=session, chat_on=False, foreground=False)
|
||||||
# Subclass fbchat.Client and override required methods
|
|
||||||
class EchoBot(Client):
|
|
||||||
def onMessage(self, author_id, message_object, thread_id, thread_type, **kwargs):
|
|
||||||
self.markAsDelivered(thread_id, message_object.uid)
|
|
||||||
self.markAsRead(thread_id)
|
|
||||||
|
|
||||||
log.info("{} from {} in {}".format(message_object, thread_id, thread_type.name))
|
|
||||||
|
|
||||||
|
for event in listener.listen():
|
||||||
|
if isinstance(event, fbchat.MessageEvent):
|
||||||
|
print(f"{event.message.text} from {event.author.id} in {event.thread.id}")
|
||||||
# If you're not the author, echo
|
# If you're not the author, echo
|
||||||
if author_id != self.uid:
|
if event.author.id != session.user.id:
|
||||||
self.send(message_object, thread_id=thread_id, thread_type=thread_type)
|
event.thread.send_text(event.message.text)
|
||||||
|
|
||||||
|
|
||||||
client = EchoBot("<email>", "<password>")
|
|
||||||
client.listen()
|
|
||||||
|
|||||||
@@ -1,46 +1,50 @@
|
|||||||
# -*- coding: UTF-8 -*-
|
import fbchat
|
||||||
|
|
||||||
from fbchat import Client
|
session = fbchat.Session.login("<email>", "<password>")
|
||||||
from fbchat.models import *
|
|
||||||
|
|
||||||
client = Client("<email>", "<password>")
|
client = fbchat.Client(session=session)
|
||||||
|
|
||||||
# Fetches a list of all users you're currently chatting with, as `User` objects
|
# Fetches a list of all users you're currently chatting with, as `User` objects
|
||||||
users = client.fetchAllUsers()
|
users = client.fetch_all_users()
|
||||||
|
|
||||||
print("users' IDs: {}".format([user.uid for user in users]))
|
print("users' IDs: {}".format([user.id for user in users]))
|
||||||
print("users' names: {}".format([user.name for user in users]))
|
print("users' names: {}".format([user.name for user in users]))
|
||||||
|
|
||||||
|
|
||||||
# If we have a user id, we can use `fetchUserInfo` to fetch a `User` object
|
# If we have a user id, we can use `fetch_user_info` to fetch a `User` object
|
||||||
user = client.fetchUserInfo("<user id>")["<user id>"]
|
user = client.fetch_user_info("<user id>")["<user id>"]
|
||||||
# We can also query both mutiple users together, which returns list of `User` objects
|
# We can also query both mutiple users together, which returns list of `User` objects
|
||||||
users = client.fetchUserInfo("<1st user id>", "<2nd user id>", "<3rd user id>")
|
users = client.fetch_user_info("<1st user id>", "<2nd user id>", "<3rd user id>")
|
||||||
|
|
||||||
print("user's name: {}".format(user.name))
|
print("user's name: {}".format(user.name))
|
||||||
print("users' names: {}".format([users[k].name for k in users]))
|
print("users' names: {}".format([users[k].name for k in users]))
|
||||||
|
|
||||||
|
|
||||||
# `searchForUsers` searches for the user and gives us a list of the results,
|
# `search_for_users` searches for the user and gives us a list of the results,
|
||||||
# and then we just take the first one, aka. the most likely one:
|
# and then we just take the first one, aka. the most likely one:
|
||||||
user = client.searchForUsers("<name of user>")[0]
|
user = client.search_for_users("<name of user>")[0]
|
||||||
|
|
||||||
print("user ID: {}".format(user.uid))
|
print("user ID: {}".format(user.id))
|
||||||
print("user's name: {}".format(user.name))
|
print("user's name: {}".format(user.name))
|
||||||
print("user's photo: {}".format(user.photo))
|
print("user's photo: {}".format(user.photo))
|
||||||
print("Is user client's friend: {}".format(user.is_friend))
|
print("Is user client's friend: {}".format(user.is_friend))
|
||||||
|
|
||||||
|
|
||||||
# Fetches a list of the 20 top threads you're currently chatting with
|
# Fetches a list of the 20 top threads you're currently chatting with
|
||||||
threads = client.fetchThreadList()
|
threads = client.fetch_thread_list()
|
||||||
# Fetches the next 10 threads
|
# Fetches the next 10 threads
|
||||||
threads += client.fetchThreadList(offset=20, limit=10)
|
threads += client.fetch_thread_list(offset=20, limit=10)
|
||||||
|
|
||||||
print("Threads: {}".format(threads))
|
print("Threads: {}".format(threads))
|
||||||
|
|
||||||
|
|
||||||
|
# If we have a thread id, we can use `fetch_thread_info` to fetch a `Thread` object
|
||||||
|
thread = client.fetch_thread_info("<thread id>")["<thread id>"]
|
||||||
|
print("thread's name: {}".format(thread.name))
|
||||||
|
|
||||||
|
|
||||||
# Gets the last 10 messages sent to the thread
|
# Gets the last 10 messages sent to the thread
|
||||||
messages = client.fetchThreadMessages(thread_id="<thread id>", limit=10)
|
messages = thread.fetch_messages(limit=10)
|
||||||
# Since the message come in reversed order, reverse them
|
# Since the message come in reversed order, reverse them
|
||||||
messages.reverse()
|
messages.reverse()
|
||||||
|
|
||||||
@@ -49,16 +53,17 @@ for message in messages:
|
|||||||
print(message.text)
|
print(message.text)
|
||||||
|
|
||||||
|
|
||||||
# If we have a thread id, we can use `fetchThreadInfo` to fetch a `Thread` object
|
# `search_for_threads` searches works like `search_for_users`, but gives us a list of threads instead
|
||||||
thread = client.fetchThreadInfo("<thread id>")["<thread id>"]
|
thread = client.search_for_threads("<name of thread>")[0]
|
||||||
print("thread's name: {}".format(thread.name))
|
print("thread's name: {}".format(thread.name))
|
||||||
print("thread's type: {}".format(thread.type))
|
|
||||||
|
|
||||||
|
|
||||||
# `searchForThreads` searches works like `searchForUsers`, but gives us a list of threads instead
|
|
||||||
thread = client.searchForThreads("<name of thread>")[0]
|
|
||||||
print("thread's name: {}".format(thread.name))
|
|
||||||
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 up to 20 last images from thread.
|
||||||
|
images = list(thread.fetch_images(limit=20))
|
||||||
|
for image in images:
|
||||||
|
if isinstance(image, fbchat.ImageAttachment):
|
||||||
|
url = client.fetch_image_url(image.id)
|
||||||
|
print(url)
|
||||||
|
|||||||
@@ -1,93 +1,66 @@
|
|||||||
# -*- coding: UTF-8 -*-
|
import fbchat
|
||||||
|
import requests
|
||||||
|
|
||||||
from fbchat import Client
|
session = fbchat.Session.login("<email>", "<password>")
|
||||||
from fbchat.models import *
|
|
||||||
|
|
||||||
client = Client("<email>", "<password>")
|
client = fbchat.Client(session)
|
||||||
|
|
||||||
thread_id = "1234567890"
|
thread = session.user
|
||||||
thread_type = ThreadType.GROUP
|
# thread = fbchat.User(session=session, id="0987654321")
|
||||||
|
# thread = fbchat.Group(session=session, id="1234567890")
|
||||||
|
|
||||||
# Will send a message to the thread
|
# Will send a message to the thread
|
||||||
client.send(Message(text="<message>"), thread_id=thread_id, thread_type=thread_type)
|
thread.send_text("<message>")
|
||||||
|
|
||||||
# Will send the default `like` emoji
|
# Will send the default `like` emoji
|
||||||
client.send(
|
thread.send_sticker(fbchat.EmojiSize.LARGE.value)
|
||||||
Message(emoji_size=EmojiSize.LARGE), thread_id=thread_id, thread_type=thread_type
|
|
||||||
)
|
|
||||||
|
|
||||||
# Will send the emoji `👍`
|
# Will send the emoji `👍`
|
||||||
client.send(
|
thread.send_emoji("👍", size=fbchat.EmojiSize.LARGE)
|
||||||
Message(text="👍", emoji_size=EmojiSize.LARGE),
|
|
||||||
thread_id=thread_id,
|
|
||||||
thread_type=thread_type,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Will send the sticker with ID `767334476626295`
|
# Will send the sticker with ID `767334476626295`
|
||||||
client.send(
|
thread.send_sticker("767334476626295")
|
||||||
Message(sticker=Sticker("767334476626295")),
|
|
||||||
thread_id=thread_id,
|
|
||||||
thread_type=thread_type,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Will send a message with a mention
|
# Will send a message with a mention
|
||||||
client.send(
|
thread.send_text(
|
||||||
Message(
|
text="This is a @mention",
|
||||||
text="This is a @mention", mentions=[Mention(thread_id, offset=10, length=8)]
|
mentions=[fbchat.Mention(thread.id, offset=10, length=8)],
|
||||||
),
|
|
||||||
thread_id=thread_id,
|
|
||||||
thread_type=thread_type,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Will send the image located at `<image path>`
|
# Will send the image located at `<image path>`
|
||||||
client.sendLocalImage(
|
with open("<image path>", "rb") as f:
|
||||||
"<image path>",
|
files = client.upload([("image_name.png", f, "image/png")])
|
||||||
message=Message(text="This is a local image"),
|
thread.send_text(text="This is a local image", files=files)
|
||||||
thread_id=thread_id,
|
|
||||||
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(
|
r = requests.get("<image url>")
|
||||||
"<image url>",
|
files = client.upload([("image_name.png", r.content, "image/png")])
|
||||||
message=Message(text="This is a remote image"),
|
thread.send_files(files) # Alternative to .send_text
|
||||||
thread_id=thread_id,
|
|
||||||
thread_type=thread_type,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Only do these actions if the thread is a group
|
# Only do these actions if the thread is a group
|
||||||
if thread_type == ThreadType.GROUP:
|
if isinstance(thread, fbchat.Group):
|
||||||
# Will remove the user with ID `<user id>` from the thread
|
# Will remove the user with ID `<user id>` from the group
|
||||||
client.removeUserFromGroup("<user id>", thread_id=thread_id)
|
thread.remove_participant("<user id>")
|
||||||
|
# Will add the users with IDs `<1st user id>`, `<2nd user id>` and `<3th user id>` to the group
|
||||||
# Will add the user with ID `<user id>` to the thread
|
thread.add_participants(["<1st user id>", "<2nd user id>", "<3rd user id>"])
|
||||||
client.addUsersToGroup("<user id>", thread_id=thread_id)
|
# Will change the title of the group to `<title>`
|
||||||
|
thread.set_title("<title>")
|
||||||
# Will add the users with IDs `<1st user id>`, `<2nd user id>` and `<3th user id>` to the thread
|
|
||||||
client.addUsersToGroup(
|
|
||||||
["<1st user id>", "<2nd user id>", "<3rd user id>"], thread_id=thread_id
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Will change the nickname of the user `<user_id>` to `<new nickname>`
|
# Will change the nickname of the user `<user id>` to `<new nickname>`
|
||||||
client.changeNickname(
|
thread.set_nickname(fbchat.User(session=session, id="<user id>"), "<new nickname>")
|
||||||
"<new nickname>", "<user id>", thread_id=thread_id, thread_type=thread_type
|
|
||||||
)
|
|
||||||
|
|
||||||
# Will change the title of the thread to `<title>`
|
# Will set the typing status of the thread
|
||||||
client.changeThreadTitle("<title>", thread_id=thread_id, thread_type=thread_type)
|
thread.start_typing()
|
||||||
|
|
||||||
# Will set the typing status of the thread to `TYPING`
|
# Will change the thread color to #0084ff
|
||||||
client.setTypingStatus(
|
thread.set_color("#0084ff")
|
||||||
TypingStatus.TYPING, thread_id=thread_id, thread_type=thread_type
|
|
||||||
)
|
|
||||||
|
|
||||||
# Will change the thread color to `MESSENGER_BLUE`
|
|
||||||
client.changeThreadColor(ThreadColor.MESSENGER_BLUE, thread_id=thread_id)
|
|
||||||
|
|
||||||
# Will change the thread emoji to `👍`
|
# Will change the thread emoji to `👍`
|
||||||
client.changeThreadEmoji("👍", thread_id=thread_id)
|
thread.set_emoji("👍")
|
||||||
|
|
||||||
|
message = fbchat.Message(thread=thread, id="<message id>")
|
||||||
|
|
||||||
# Will react to a message with a 😍 emoji
|
# Will react to a message with a 😍 emoji
|
||||||
client.reactToMessage("<message id>", MessageReaction.LOVE)
|
message.react("😍")
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
# -*- coding: UTF-8 -*-
|
# This example uses the `blinker` library to dispatch events. See echobot.py for how
|
||||||
|
# this could be done differenly. The decision is entirely up to you!
|
||||||
from fbchat import log, Client
|
import fbchat
|
||||||
from fbchat.models import *
|
import blinker
|
||||||
|
|
||||||
# Change this to your group id
|
# Change this to your group id
|
||||||
old_thread_id = "1234567890"
|
old_thread_id = "1234567890"
|
||||||
|
|
||||||
# Change these to match your liking
|
# Change these to match your liking
|
||||||
old_color = ThreadColor.MESSENGER_BLUE
|
old_color = "#0084ff"
|
||||||
old_emoji = "👍"
|
old_emoji = "👍"
|
||||||
old_title = "Old group chat name"
|
old_title = "Old group chat name"
|
||||||
old_nicknames = {
|
old_nicknames = {
|
||||||
@@ -17,67 +17,76 @@ old_nicknames = {
|
|||||||
"12345678904": "User nr. 4's nickname",
|
"12345678904": "User nr. 4's nickname",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Create a blinker signal
|
||||||
|
events = blinker.Signal()
|
||||||
|
|
||||||
class KeepBot(Client):
|
# Register various event handlers on the signal
|
||||||
def onColorChange(self, author_id, new_color, thread_id, thread_type, **kwargs):
|
@events.connect_via(fbchat.ColorSet)
|
||||||
if old_thread_id == thread_id and old_color != new_color:
|
def on_color_set(sender, event: fbchat.ColorSet):
|
||||||
log.info(
|
if old_thread_id != event.thread.id:
|
||||||
"{} changed the thread color. It will be changed back".format(author_id)
|
return
|
||||||
)
|
if old_color != event.color:
|
||||||
self.changeThreadColor(old_color, thread_id=thread_id)
|
print(f"{event.author.id} changed the thread color. It will be changed back")
|
||||||
|
event.thread.set_color(old_color)
|
||||||
def onEmojiChange(self, author_id, new_emoji, thread_id, thread_type, **kwargs):
|
|
||||||
if old_thread_id == thread_id and new_emoji != old_emoji:
|
|
||||||
log.info(
|
|
||||||
"{} changed the thread emoji. It will be changed back".format(author_id)
|
|
||||||
)
|
|
||||||
self.changeThreadEmoji(old_emoji, thread_id=thread_id)
|
|
||||||
|
|
||||||
def onPeopleAdded(self, added_ids, author_id, thread_id, **kwargs):
|
|
||||||
if old_thread_id == thread_id and author_id != self.uid:
|
|
||||||
log.info("{} got added. They will be removed".format(added_ids))
|
|
||||||
for added_id in added_ids:
|
|
||||||
self.removeUserFromGroup(added_id, thread_id=thread_id)
|
|
||||||
|
|
||||||
def onPersonRemoved(self, removed_id, author_id, thread_id, **kwargs):
|
|
||||||
# No point in trying to add ourself
|
|
||||||
if (
|
|
||||||
old_thread_id == thread_id
|
|
||||||
and removed_id != self.uid
|
|
||||||
and author_id != self.uid
|
|
||||||
):
|
|
||||||
log.info("{} got removed. They will be re-added".format(removed_id))
|
|
||||||
self.addUsersToGroup(removed_id, thread_id=thread_id)
|
|
||||||
|
|
||||||
def onTitleChange(self, author_id, new_title, thread_id, thread_type, **kwargs):
|
|
||||||
if old_thread_id == thread_id and old_title != new_title:
|
|
||||||
log.info(
|
|
||||||
"{} changed the thread title. It will be changed back".format(author_id)
|
|
||||||
)
|
|
||||||
self.changeThreadTitle(
|
|
||||||
old_title, thread_id=thread_id, thread_type=thread_type
|
|
||||||
)
|
|
||||||
|
|
||||||
def onNicknameChange(
|
|
||||||
self, author_id, changed_for, new_nickname, thread_id, thread_type, **kwargs
|
|
||||||
):
|
|
||||||
if (
|
|
||||||
old_thread_id == thread_id
|
|
||||||
and changed_for in old_nicknames
|
|
||||||
and old_nicknames[changed_for] != new_nickname
|
|
||||||
):
|
|
||||||
log.info(
|
|
||||||
"{} changed {}'s' nickname. It will be changed back".format(
|
|
||||||
author_id, changed_for
|
|
||||||
)
|
|
||||||
)
|
|
||||||
self.changeNickname(
|
|
||||||
old_nicknames[changed_for],
|
|
||||||
changed_for,
|
|
||||||
thread_id=thread_id,
|
|
||||||
thread_type=thread_type,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
client = KeepBot("<email>", "<password>")
|
@events.connect_via(fbchat.EmojiSet)
|
||||||
client.listen()
|
def on_emoji_set(sender, event: fbchat.EmojiSet):
|
||||||
|
if old_thread_id != event.thread.id:
|
||||||
|
return
|
||||||
|
if old_emoji != event.emoji:
|
||||||
|
print(f"{event.author.id} changed the thread emoji. It will be changed back")
|
||||||
|
event.thread.set_emoji(old_emoji)
|
||||||
|
|
||||||
|
|
||||||
|
@events.connect_via(fbchat.TitleSet)
|
||||||
|
def on_title_set(sender, event: fbchat.TitleSet):
|
||||||
|
if old_thread_id != event.thread.id:
|
||||||
|
return
|
||||||
|
if old_title != event.title:
|
||||||
|
print(f"{event.author.id} changed the thread title. It will be changed back")
|
||||||
|
event.thread.set_title(old_title)
|
||||||
|
|
||||||
|
|
||||||
|
@events.connect_via(fbchat.NicknameSet)
|
||||||
|
def on_nickname_set(sender, event: fbchat.NicknameSet):
|
||||||
|
if old_thread_id != event.thread.id:
|
||||||
|
return
|
||||||
|
old_nickname = old_nicknames.get(event.subject.id)
|
||||||
|
if old_nickname != event.nickname:
|
||||||
|
print(
|
||||||
|
f"{event.author.id} changed {event.subject.id}'s' nickname."
|
||||||
|
" It will be changed back"
|
||||||
|
)
|
||||||
|
event.thread.set_nickname(event.subject.id, old_nickname)
|
||||||
|
|
||||||
|
|
||||||
|
@events.connect_via(fbchat.PeopleAdded)
|
||||||
|
def on_people_added(sender, event: fbchat.PeopleAdded):
|
||||||
|
if old_thread_id != event.thread.id:
|
||||||
|
return
|
||||||
|
if event.author.id != session.user.id:
|
||||||
|
print(f"{', '.join(x.id for x in event.added)} got added. They will be removed")
|
||||||
|
for added in event.added:
|
||||||
|
event.thread.remove_participant(added.id)
|
||||||
|
|
||||||
|
|
||||||
|
@events.connect_via(fbchat.PersonRemoved)
|
||||||
|
def on_person_removed(sender, event: fbchat.PersonRemoved):
|
||||||
|
if old_thread_id != event.thread.id:
|
||||||
|
return
|
||||||
|
# No point in trying to add ourself
|
||||||
|
if event.removed.id == session.user.id:
|
||||||
|
return
|
||||||
|
if event.author.id != session.user.id:
|
||||||
|
print(f"{event.removed.id} got removed. They will be re-added")
|
||||||
|
event.thread.add_participants([event.removed.id])
|
||||||
|
|
||||||
|
|
||||||
|
# Login, and start listening for events
|
||||||
|
session = fbchat.Session.login("<email>", "<password>")
|
||||||
|
listener = fbchat.Listener(session=session, chat_on=False, foreground=False)
|
||||||
|
|
||||||
|
for event in listener.listen():
|
||||||
|
# Dispatch the event to the subscribed handlers
|
||||||
|
events.send(type(event), event=event)
|
||||||
|
|||||||
@@ -1,25 +1,17 @@
|
|||||||
# -*- coding: UTF-8 -*-
|
import fbchat
|
||||||
|
|
||||||
from fbchat import log, Client
|
|
||||||
from fbchat.models import *
|
|
||||||
|
|
||||||
|
|
||||||
class RemoveBot(Client):
|
def on_message(event):
|
||||||
def onMessage(self, author_id, message_object, thread_id, thread_type, **kwargs):
|
# We can only kick people from group chats, so no need to try if it's a user chat
|
||||||
# We can only kick people from group chats, so no need to try if it's a user chat
|
if not isinstance(event.thread, fbchat.Group):
|
||||||
if message_object.text == "Remove me!" and thread_type == ThreadType.GROUP:
|
return
|
||||||
log.info("{} will be removed from {}".format(author_id, thread_id))
|
if event.message.text == "Remove me!":
|
||||||
self.removeUserFromGroup(author_id, thread_id=thread_id)
|
print(f"{event.author.id} will be removed from {event.thread.id}")
|
||||||
else:
|
event.thread.remove_participant(event.author.id)
|
||||||
# Sends the data to the inherited onMessage, so that we can still see when a message is recieved
|
|
||||||
super(RemoveBot, self).onMessage(
|
|
||||||
author_id=author_id,
|
|
||||||
message_object=message_object,
|
|
||||||
thread_id=thread_id,
|
|
||||||
thread_type=thread_type,
|
|
||||||
**kwargs
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
client = RemoveBot("<email>", "<password>")
|
session = fbchat.Session.login("<email>", "<password>")
|
||||||
client.listen()
|
listener = fbchat.Listener(session=session, chat_on=False, foreground=False)
|
||||||
|
for event in listener.listen():
|
||||||
|
if isinstance(event, fbchat.MessageEvent):
|
||||||
|
on_message(event)
|
||||||
|
|||||||
42
examples/session_handling.py
Normal file
42
examples/session_handling.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# TODO: Consider adding Session.from_file and Session.to_file,
|
||||||
|
# which would make this example a lot easier!
|
||||||
|
|
||||||
|
import atexit
|
||||||
|
import json
|
||||||
|
import getpass
|
||||||
|
import fbchat
|
||||||
|
|
||||||
|
|
||||||
|
def load_cookies(filename):
|
||||||
|
try:
|
||||||
|
# Load cookies from file
|
||||||
|
with open(filename) as f:
|
||||||
|
return json.load(f)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return # No cookies yet
|
||||||
|
|
||||||
|
|
||||||
|
def save_cookies(filename, cookies):
|
||||||
|
with open(filename, "w") as f:
|
||||||
|
json.dump(cookies, f)
|
||||||
|
|
||||||
|
|
||||||
|
def load_session(cookies):
|
||||||
|
if not cookies:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
return fbchat.Session.from_cookies(cookies)
|
||||||
|
except fbchat.FacebookError:
|
||||||
|
return # Failed loading from cookies
|
||||||
|
|
||||||
|
|
||||||
|
cookies = load_cookies("session.json")
|
||||||
|
session = load_session(cookies)
|
||||||
|
if not session:
|
||||||
|
# Session could not be loaded, login instead!
|
||||||
|
session = fbchat.Session.login("<email>", getpass.getpass())
|
||||||
|
|
||||||
|
# Save session cookies to file when the program exits
|
||||||
|
atexit.register(lambda: save_cookies("session.json", session.get_cookies()))
|
||||||
|
|
||||||
|
# Do stuff with session here
|
||||||
@@ -1,25 +1,129 @@
|
|||||||
# -*- coding: UTF-8 -*-
|
"""Facebook Messenger for Python.
|
||||||
"""Facebook Chat (Messenger) for Python
|
|
||||||
|
|
||||||
:copyright: (c) 2015 - 2019 by Taehoon Kim
|
Copyright:
|
||||||
:license: BSD 3-Clause, see LICENSE for more details.
|
(c) 2015 - 2018 by Taehoon Kim
|
||||||
|
(c) 2018 - 2020 by Mads Marquart
|
||||||
|
|
||||||
|
License:
|
||||||
|
BSD 3-Clause, see LICENSE for more details.
|
||||||
"""
|
"""
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
# These imports are far too general, but they're needed for backwards compatbility.
|
import logging as _logging
|
||||||
from .models import *
|
|
||||||
|
# Set default logging handler to avoid "No handler found" warnings.
|
||||||
|
_logging.getLogger(__name__).addHandler(_logging.NullHandler())
|
||||||
|
|
||||||
|
# The order of these is somewhat significant, e.g. User has to be imported after Thread!
|
||||||
|
from . import _common, _util
|
||||||
|
from ._exception import (
|
||||||
|
FacebookError,
|
||||||
|
HTTPError,
|
||||||
|
ParseError,
|
||||||
|
ExternalError,
|
||||||
|
GraphQLError,
|
||||||
|
InvalidParameters,
|
||||||
|
NotLoggedIn,
|
||||||
|
PleaseRefresh,
|
||||||
|
)
|
||||||
|
from ._session import Session
|
||||||
|
from ._threads import (
|
||||||
|
ThreadABC,
|
||||||
|
Thread,
|
||||||
|
User,
|
||||||
|
UserData,
|
||||||
|
Group,
|
||||||
|
GroupData,
|
||||||
|
Page,
|
||||||
|
PageData,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Models
|
||||||
|
from ._models import (
|
||||||
|
Image,
|
||||||
|
ThreadLocation,
|
||||||
|
ActiveStatus,
|
||||||
|
Attachment,
|
||||||
|
UnsentMessage,
|
||||||
|
ShareAttachment,
|
||||||
|
LocationAttachment,
|
||||||
|
LiveLocationAttachment,
|
||||||
|
Sticker,
|
||||||
|
FileAttachment,
|
||||||
|
AudioAttachment,
|
||||||
|
ImageAttachment,
|
||||||
|
VideoAttachment,
|
||||||
|
Poll,
|
||||||
|
PollOption,
|
||||||
|
GuestStatus,
|
||||||
|
Plan,
|
||||||
|
PlanData,
|
||||||
|
QuickReply,
|
||||||
|
QuickReplyText,
|
||||||
|
QuickReplyLocation,
|
||||||
|
QuickReplyPhoneNumber,
|
||||||
|
QuickReplyEmail,
|
||||||
|
EmojiSize,
|
||||||
|
Mention,
|
||||||
|
Message,
|
||||||
|
MessageSnippet,
|
||||||
|
MessageData,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Events
|
||||||
|
from ._events import (
|
||||||
|
# _common
|
||||||
|
Event,
|
||||||
|
UnknownEvent,
|
||||||
|
ThreadEvent,
|
||||||
|
Connect,
|
||||||
|
Disconnect,
|
||||||
|
# _client_payload
|
||||||
|
ReactionEvent,
|
||||||
|
UserStatusEvent,
|
||||||
|
LiveLocationEvent,
|
||||||
|
UnsendEvent,
|
||||||
|
MessageReplyEvent,
|
||||||
|
# _delta_class
|
||||||
|
PeopleAdded,
|
||||||
|
PersonRemoved,
|
||||||
|
TitleSet,
|
||||||
|
UnfetchedThreadEvent,
|
||||||
|
MessagesDelivered,
|
||||||
|
ThreadsRead,
|
||||||
|
MessageEvent,
|
||||||
|
ThreadFolder,
|
||||||
|
# _delta_type
|
||||||
|
ColorSet,
|
||||||
|
EmojiSet,
|
||||||
|
NicknameSet,
|
||||||
|
AdminsAdded,
|
||||||
|
AdminsRemoved,
|
||||||
|
ApprovalModeSet,
|
||||||
|
CallStarted,
|
||||||
|
CallEnded,
|
||||||
|
CallJoined,
|
||||||
|
PollCreated,
|
||||||
|
PollVoted,
|
||||||
|
PlanCreated,
|
||||||
|
PlanEnded,
|
||||||
|
PlanEdited,
|
||||||
|
PlanDeleted,
|
||||||
|
PlanResponded,
|
||||||
|
# __init__
|
||||||
|
Typing,
|
||||||
|
FriendRequest,
|
||||||
|
Presence,
|
||||||
|
)
|
||||||
|
from ._listen import Listener
|
||||||
|
|
||||||
from ._client import Client
|
from ._client import Client
|
||||||
from ._util import log # TODO: Remove this (from examples too)
|
|
||||||
|
|
||||||
__title__ = "fbchat"
|
__version__ = "2.0.0a5"
|
||||||
__version__ = "1.7.2"
|
|
||||||
__description__ = "Facebook Chat (Messenger) for Python"
|
|
||||||
|
|
||||||
__copyright__ = "Copyright 2015 - 2019 by Taehoon Kim"
|
__all__ = ("Session", "Listener", "Client")
|
||||||
__license__ = "BSD 3-Clause"
|
|
||||||
|
|
||||||
__author__ = "Taehoon Kim; Moreels Pieter-Jan; Mads Marquart"
|
|
||||||
__email__ = "carpedm20@gmail.com"
|
|
||||||
|
|
||||||
__all__ = ["Client"]
|
from . import _fix_module_metadata
|
||||||
|
|
||||||
|
_fix_module_metadata.fixup_module_metadata(globals())
|
||||||
|
del _fix_module_metadata
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
# -*- coding: UTF-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
import attr
|
|
||||||
from . import _util
|
|
||||||
|
|
||||||
|
|
||||||
@attr.s(cmp=False)
|
|
||||||
class Attachment(object):
|
|
||||||
"""Represents a Facebook attachment"""
|
|
||||||
|
|
||||||
#: The attachment ID
|
|
||||||
uid = attr.ib(None)
|
|
||||||
|
|
||||||
|
|
||||||
@attr.s(cmp=False)
|
|
||||||
class UnsentMessage(Attachment):
|
|
||||||
"""Represents an unsent message attachment"""
|
|
||||||
|
|
||||||
|
|
||||||
@attr.s(cmp=False)
|
|
||||||
class ShareAttachment(Attachment):
|
|
||||||
"""Represents a shared item (eg. URL) that has been sent as a Facebook attachment"""
|
|
||||||
|
|
||||||
#: ID of the author of the shared post
|
|
||||||
author = attr.ib(None)
|
|
||||||
#: Target URL
|
|
||||||
url = attr.ib(None)
|
|
||||||
#: Original URL if Facebook redirects the URL
|
|
||||||
original_url = attr.ib(None)
|
|
||||||
#: Title of the attachment
|
|
||||||
title = attr.ib(None)
|
|
||||||
#: Description of the attachment
|
|
||||||
description = attr.ib(None)
|
|
||||||
#: Name of the source
|
|
||||||
source = attr.ib(None)
|
|
||||||
#: URL of the attachment image
|
|
||||||
image_url = attr.ib(None)
|
|
||||||
#: URL of the original image if Facebook uses ``safe_image``
|
|
||||||
original_image_url = attr.ib(None)
|
|
||||||
#: Width of the image
|
|
||||||
image_width = attr.ib(None)
|
|
||||||
#: Height of the image
|
|
||||||
image_height = attr.ib(None)
|
|
||||||
#: List of additional attachments
|
|
||||||
attachments = attr.ib(factory=list, converter=lambda x: [] if x is None else x)
|
|
||||||
|
|
||||||
# Put here for backwards compatibility, so that the init argument order is preserved
|
|
||||||
uid = attr.ib(None)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _from_graphql(cls, data):
|
|
||||||
from . import _file
|
|
||||||
|
|
||||||
url = data.get("url")
|
|
||||||
rtn = cls(
|
|
||||||
uid=data.get("deduplication_key"),
|
|
||||||
author=data["target"]["actors"][0]["id"]
|
|
||||||
if data["target"].get("actors")
|
|
||||||
else None,
|
|
||||||
url=url,
|
|
||||||
original_url=_util.get_url_parameter(url, "u")
|
|
||||||
if "/l.php?u=" in url
|
|
||||||
else url,
|
|
||||||
title=data["title_with_entities"].get("text"),
|
|
||||||
description=data["description"].get("text")
|
|
||||||
if data.get("description")
|
|
||||||
else None,
|
|
||||||
source=data["source"].get("text") if data.get("source") else None,
|
|
||||||
attachments=[
|
|
||||||
_file.graphql_to_subattachment(attachment)
|
|
||||||
for attachment in data.get("subattachments")
|
|
||||||
],
|
|
||||||
)
|
|
||||||
media = data.get("media")
|
|
||||||
if media and media.get("image"):
|
|
||||||
image = media["image"]
|
|
||||||
rtn.image_url = image.get("uri")
|
|
||||||
rtn.original_image_url = (
|
|
||||||
_util.get_url_parameter(rtn.image_url, "url")
|
|
||||||
if "/safe_image.php" in rtn.image_url
|
|
||||||
else rtn.image_url
|
|
||||||
)
|
|
||||||
rtn.image_width = image.get("width")
|
|
||||||
rtn.image_height = image.get("height")
|
|
||||||
return rtn
|
|
||||||
4149
fbchat/_client.py
4149
fbchat/_client.py
File diff suppressed because it is too large
Load Diff
11
fbchat/_common.py
Normal file
11
fbchat/_common.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import sys
|
||||||
|
import attr
|
||||||
|
import logging
|
||||||
|
|
||||||
|
log = logging.getLogger("fbchat")
|
||||||
|
|
||||||
|
# Enable kw_only if the python version supports it
|
||||||
|
kw_only = sys.version_info[:2] > (3, 5)
|
||||||
|
|
||||||
|
#: Default attrs settings for classes
|
||||||
|
attrs_default = attr.s(frozen=True, slots=True, kw_only=kw_only)
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
# -*- coding: UTF-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import aenum
|
|
||||||
|
|
||||||
log = logging.getLogger("client")
|
|
||||||
|
|
||||||
|
|
||||||
class Enum(aenum.Enum):
|
|
||||||
"""Used internally by fbchat to support enumerations"""
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
# For documentation:
|
|
||||||
return "{}.{}".format(type(self).__name__, self.name)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _extend_if_invalid(cls, value):
|
|
||||||
try:
|
|
||||||
return cls(value)
|
|
||||||
except ValueError:
|
|
||||||
log.warning(
|
|
||||||
"Failed parsing {.__name__}({!r}). Extending enum.".format(cls, value)
|
|
||||||
)
|
|
||||||
aenum.extend_enum(cls, "UNKNOWN_{}".format(value).upper(), value)
|
|
||||||
return cls(value)
|
|
||||||
132
fbchat/_events/__init__.py
Normal file
132
fbchat/_events/__init__.py
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import attr
|
||||||
|
import datetime
|
||||||
|
from ._common import attrs_event, Event, UnknownEvent, ThreadEvent
|
||||||
|
from ._client_payload import *
|
||||||
|
from ._delta_class import *
|
||||||
|
from ._delta_type import *
|
||||||
|
|
||||||
|
from .. import _exception, _threads, _models
|
||||||
|
|
||||||
|
from typing import Mapping
|
||||||
|
|
||||||
|
|
||||||
|
@attrs_event
|
||||||
|
class Typing(ThreadEvent):
|
||||||
|
"""Somebody started/stopped typing in a thread."""
|
||||||
|
|
||||||
|
#: ``True`` if the user started typing, ``False`` if they stopped
|
||||||
|
status = attr.ib(type=bool)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _parse_orca(cls, session, data):
|
||||||
|
author = _threads.User(session=session, id=str(data["sender_fbid"]))
|
||||||
|
status = data["state"] == 1
|
||||||
|
return cls(author=author, thread=author, status=status)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _parse_thread_typing(cls, session, data):
|
||||||
|
author = _threads.User(session=session, id=str(data["sender_fbid"]))
|
||||||
|
thread = _threads.Group(session=session, id=str(data["thread"]))
|
||||||
|
status = data["state"] == 1
|
||||||
|
return cls(author=author, thread=thread, status=status)
|
||||||
|
|
||||||
|
|
||||||
|
@attrs_event
|
||||||
|
class FriendRequest(Event):
|
||||||
|
"""Somebody sent a friend request."""
|
||||||
|
|
||||||
|
#: The user that sent the request
|
||||||
|
author = attr.ib(type="_threads.User")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _parse(cls, session, data):
|
||||||
|
author = _threads.User(session=session, id=str(data["from"]))
|
||||||
|
return cls(author=author)
|
||||||
|
|
||||||
|
|
||||||
|
@attrs_event
|
||||||
|
class Presence(Event):
|
||||||
|
"""The list of active statuses was updated.
|
||||||
|
|
||||||
|
Chat online presence update.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# TODO: Document this better!
|
||||||
|
|
||||||
|
#: User ids mapped to their active status
|
||||||
|
statuses = attr.ib(type=Mapping[str, "_models.ActiveStatus"])
|
||||||
|
#: ``True`` if the list is fully updated and ``False`` if it's partially updated
|
||||||
|
full = attr.ib(type=bool)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _parse(cls, session, data):
|
||||||
|
statuses = {
|
||||||
|
str(d["u"]): _models.ActiveStatus._from_orca_presence(d)
|
||||||
|
for d in data["list"]
|
||||||
|
}
|
||||||
|
return cls(statuses=statuses, full=data["list_type"] == "full")
|
||||||
|
|
||||||
|
|
||||||
|
@attrs_event
|
||||||
|
class Connect(Event):
|
||||||
|
"""The client was connected to Facebook.
|
||||||
|
|
||||||
|
This is not guaranteed to be triggered the same amount of times `Disconnect`!
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@attrs_event
|
||||||
|
class Disconnect(Event):
|
||||||
|
"""The client lost the connection to Facebook.
|
||||||
|
|
||||||
|
This is not guaranteed to be triggered the same amount of times `Connect`!
|
||||||
|
"""
|
||||||
|
|
||||||
|
#: The reason / error string for the disconnect
|
||||||
|
reason = attr.ib(type=str)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_events(session, topic, data):
|
||||||
|
# See Mqtt._configure_connect_options for information about these topics
|
||||||
|
try:
|
||||||
|
if topic == "/t_ms":
|
||||||
|
# `deltas` will always be available, since we're filtering out the things
|
||||||
|
# that don't have it earlier in the MQTT listener
|
||||||
|
for delta in data["deltas"]:
|
||||||
|
if delta["class"] == "ClientPayload":
|
||||||
|
yield from parse_client_payloads(session, delta)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
event = parse_delta(session, delta)
|
||||||
|
if event: # Skip `None`
|
||||||
|
yield event
|
||||||
|
except _exception.ParseError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise _exception.ParseError(
|
||||||
|
"Error parsing delta", data=delta
|
||||||
|
) from e
|
||||||
|
|
||||||
|
elif topic == "/thread_typing":
|
||||||
|
yield Typing._parse_thread_typing(session, data)
|
||||||
|
|
||||||
|
elif topic == "/orca_typing_notifications":
|
||||||
|
yield Typing._parse_orca(session, data)
|
||||||
|
|
||||||
|
elif topic == "/legacy_web":
|
||||||
|
if data["type"] == "jewel_requests_add":
|
||||||
|
yield FriendRequest._parse(session, data)
|
||||||
|
else:
|
||||||
|
yield UnknownEvent(source="/legacy_web", data=data)
|
||||||
|
|
||||||
|
elif topic == "/orca_presence":
|
||||||
|
yield Presence._parse(session, data)
|
||||||
|
|
||||||
|
else:
|
||||||
|
yield UnknownEvent(source=topic, data=data)
|
||||||
|
except _exception.ParseError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise _exception.ParseError(
|
||||||
|
"Error parsing MQTT topic {}".format(topic), data=data
|
||||||
|
) from e
|
||||||
136
fbchat/_events/_client_payload.py
Normal file
136
fbchat/_events/_client_payload.py
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import attr
|
||||||
|
import datetime
|
||||||
|
from ._common import attrs_event, UnknownEvent, ThreadEvent
|
||||||
|
from .. import _exception, _util, _threads, _models
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
@attrs_event
|
||||||
|
class ReactionEvent(ThreadEvent):
|
||||||
|
"""Somebody reacted to a message."""
|
||||||
|
|
||||||
|
#: Message that the user reacted to
|
||||||
|
message = attr.ib(type="_models.Message")
|
||||||
|
|
||||||
|
reaction = attr.ib(type=Optional[str])
|
||||||
|
"""The reaction.
|
||||||
|
|
||||||
|
Not limited to the ones in `Message.react`.
|
||||||
|
|
||||||
|
If ``None``, the reaction was removed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _parse(cls, session, data):
|
||||||
|
thread = cls._get_thread(session, data)
|
||||||
|
return cls(
|
||||||
|
author=_threads.User(session=session, id=str(data["userId"])),
|
||||||
|
thread=thread,
|
||||||
|
message=_models.Message(thread=thread, id=data["messageId"]),
|
||||||
|
reaction=data["reaction"] if data["action"] == 0 else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@attrs_event
|
||||||
|
class UserStatusEvent(ThreadEvent):
|
||||||
|
#: Whether the user was blocked or unblocked
|
||||||
|
blocked = attr.ib(type=bool)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _parse(cls, session, data):
|
||||||
|
return cls(
|
||||||
|
author=_threads.User(session=session, id=str(data["actorFbid"])),
|
||||||
|
thread=cls._get_thread(session, data),
|
||||||
|
blocked=not data["canViewerReply"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@attrs_event
|
||||||
|
class LiveLocationEvent(ThreadEvent):
|
||||||
|
"""Somebody sent live location info."""
|
||||||
|
|
||||||
|
# TODO: This!
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _parse(cls, session, data):
|
||||||
|
from . import _location
|
||||||
|
|
||||||
|
thread = cls._get_thread(session, data)
|
||||||
|
for location_data in data["messageLiveLocations"]:
|
||||||
|
message = _models.Message(thread=thread, id=data["messageId"])
|
||||||
|
author = _threads.User(session=session, id=str(location_data["senderId"]))
|
||||||
|
location = _location.LiveLocationAttachment._from_pull(location_data)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@attrs_event
|
||||||
|
class UnsendEvent(ThreadEvent):
|
||||||
|
"""Somebody unsent a message (which deletes it for everyone)."""
|
||||||
|
|
||||||
|
#: The unsent message
|
||||||
|
message = attr.ib(type="_models.Message")
|
||||||
|
#: When the message was unsent
|
||||||
|
at = attr.ib(type=datetime.datetime)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _parse(cls, session, data):
|
||||||
|
thread = cls._get_thread(session, data)
|
||||||
|
return cls(
|
||||||
|
author=_threads.User(session=session, id=str(data["senderID"])),
|
||||||
|
thread=thread,
|
||||||
|
message=_models.Message(thread=thread, id=data["messageID"]),
|
||||||
|
at=_util.millis_to_datetime(data["deletionTimestamp"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@attrs_event
|
||||||
|
class MessageReplyEvent(ThreadEvent):
|
||||||
|
"""Somebody replied to a message."""
|
||||||
|
|
||||||
|
#: The sent message
|
||||||
|
message = attr.ib(type="_models.MessageData")
|
||||||
|
#: The message that was replied to
|
||||||
|
replied_to = attr.ib(type="_models.MessageData")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _parse(cls, session, data):
|
||||||
|
metadata = data["message"]["messageMetadata"]
|
||||||
|
thread = cls._get_thread(session, metadata)
|
||||||
|
return cls(
|
||||||
|
author=_threads.User(session=session, id=str(metadata["actorFbId"])),
|
||||||
|
thread=thread,
|
||||||
|
message=_models.MessageData._from_reply(thread, data["message"]),
|
||||||
|
replied_to=_models.MessageData._from_reply(
|
||||||
|
thread, data["repliedToMessage"]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_client_delta(session, data):
|
||||||
|
if "deltaMessageReaction" in data:
|
||||||
|
return ReactionEvent._parse(session, data["deltaMessageReaction"])
|
||||||
|
elif "deltaChangeViewerStatus" in data:
|
||||||
|
# TODO: Parse all `reason`
|
||||||
|
if data["deltaChangeViewerStatus"]["reason"] == 2:
|
||||||
|
return UserStatusEvent._parse(session, data["deltaChangeViewerStatus"])
|
||||||
|
elif "liveLocationData" in data:
|
||||||
|
return LiveLocationEvent._parse(session, data["liveLocationData"])
|
||||||
|
elif "deltaRecallMessageData" in data:
|
||||||
|
return UnsendEvent._parse(session, data["deltaRecallMessageData"])
|
||||||
|
elif "deltaMessageReply" in data:
|
||||||
|
return MessageReplyEvent._parse(session, data["deltaMessageReply"])
|
||||||
|
return UnknownEvent(source="client payload", data=data)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_client_payloads(session, data):
|
||||||
|
payload = _util.parse_json("".join(chr(z) for z in data["payload"]))
|
||||||
|
|
||||||
|
try:
|
||||||
|
for delta in payload["deltas"]:
|
||||||
|
yield parse_client_delta(session, delta)
|
||||||
|
except _exception.ParseError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise _exception.ParseError("Error parsing ClientPayload", data=payload) from e
|
||||||
62
fbchat/_events/_common.py
Normal file
62
fbchat/_events/_common.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import attr
|
||||||
|
from .._common import kw_only
|
||||||
|
from .. import _exception, _util, _threads
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
#: Default attrs settings for events
|
||||||
|
attrs_event = attr.s(slots=True, kw_only=kw_only, frozen=True)
|
||||||
|
|
||||||
|
|
||||||
|
@attrs_event
|
||||||
|
class Event:
|
||||||
|
"""Base class for all events."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_thread(session, data):
|
||||||
|
# TODO: Handle pages? Is it even possible?
|
||||||
|
key = data["threadKey"]
|
||||||
|
|
||||||
|
if "threadFbId" in key:
|
||||||
|
return _threads.Group(session=session, id=str(key["threadFbId"]))
|
||||||
|
elif "otherUserFbId" in key:
|
||||||
|
return _threads.User(session=session, id=str(key["otherUserFbId"]))
|
||||||
|
raise _exception.ParseError("Could not find thread data", data=data)
|
||||||
|
|
||||||
|
|
||||||
|
@attrs_event
|
||||||
|
class UnknownEvent(Event):
|
||||||
|
"""Represent an unknown event."""
|
||||||
|
|
||||||
|
#: Some data describing the unknown event's origin
|
||||||
|
source = attr.ib(type=str)
|
||||||
|
#: The unknown data. This cannot be relied on, it's only for debugging purposes.
|
||||||
|
data = attr.ib(type=Any)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _parse(cls, session, data):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
@attrs_event
|
||||||
|
class ThreadEvent(Event):
|
||||||
|
"""Represent an event that was done by a user/page in a thread."""
|
||||||
|
|
||||||
|
#: The person who did the action
|
||||||
|
author = attr.ib(type="_threads.User") # Or Union[User, Page]?
|
||||||
|
#: Thread that the action was done in
|
||||||
|
thread = attr.ib(type="_threads.ThreadABC")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _parse_metadata(cls, session, data):
|
||||||
|
metadata = data["messageMetadata"]
|
||||||
|
author = _threads.User(session=session, id=metadata["actorFbId"])
|
||||||
|
thread = cls._get_thread(session, metadata)
|
||||||
|
at = _util.millis_to_datetime(int(metadata["timestamp"]))
|
||||||
|
return author, thread, at
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _parse_fetch(cls, session, data):
|
||||||
|
author = _threads.User(session=session, id=data["message_sender"]["id"])
|
||||||
|
at = _util.millis_to_datetime(int(data["timestamp_precise"]))
|
||||||
|
return author, at
|
||||||
214
fbchat/_events/_delta_class.py
Normal file
214
fbchat/_events/_delta_class.py
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
import attr
|
||||||
|
import datetime
|
||||||
|
from ._common import attrs_event, Event, UnknownEvent, ThreadEvent
|
||||||
|
from . import _delta_type
|
||||||
|
from .. import _util, _threads, _models
|
||||||
|
|
||||||
|
from typing import Sequence, Optional
|
||||||
|
|
||||||
|
|
||||||
|
@attrs_event
|
||||||
|
class PeopleAdded(ThreadEvent):
|
||||||
|
"""somebody added people to a group thread."""
|
||||||
|
|
||||||
|
# TODO: Add message id
|
||||||
|
|
||||||
|
thread = attr.ib(type="_threads.Group") # Set the correct type
|
||||||
|
#: The people who got added
|
||||||
|
added = attr.ib(type=Sequence["_threads.User"])
|
||||||
|
#: When the people were added
|
||||||
|
at = attr.ib(type=datetime.datetime)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _parse(cls, session, data):
|
||||||
|
author, thread, at = cls._parse_metadata(session, data)
|
||||||
|
added = [
|
||||||
|
# TODO: Parse user name
|
||||||
|
_threads.User(session=session, id=x["userFbId"])
|
||||||
|
for x in data["addedParticipants"]
|
||||||
|
]
|
||||||
|
return cls(author=author, thread=thread, added=added, at=at)
|
||||||
|
|
||||||
|
|
||||||
|
@attrs_event
|
||||||
|
class PersonRemoved(ThreadEvent):
|
||||||
|
"""Somebody removed a person from a group thread."""
|
||||||
|
|
||||||
|
# TODO: Add message id
|
||||||
|
|
||||||
|
thread = attr.ib(type="_threads.Group") # Set the correct type
|
||||||
|
#: Person who got removed
|
||||||
|
removed = attr.ib(type="_models.Message")
|
||||||
|
#: When the person were removed
|
||||||
|
at = attr.ib(type=datetime.datetime)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _parse(cls, session, data):
|
||||||
|
author, thread, at = cls._parse_metadata(session, data)
|
||||||
|
removed = _threads.User(session=session, id=data["leftParticipantFbId"])
|
||||||
|
return cls(author=author, thread=thread, removed=removed, at=at)
|
||||||
|
|
||||||
|
|
||||||
|
@attrs_event
|
||||||
|
class TitleSet(ThreadEvent):
|
||||||
|
"""Somebody changed a group's title."""
|
||||||
|
|
||||||
|
thread = attr.ib(type="_threads.Group") # Set the correct type
|
||||||
|
#: The new title. If ``None``, the title was removed
|
||||||
|
title = attr.ib(type=Optional[str])
|
||||||
|
#: When the title was set
|
||||||
|
at = attr.ib(type=datetime.datetime)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _parse(cls, session, data):
|
||||||
|
author, thread, at = cls._parse_metadata(session, data)
|
||||||
|
return cls(author=author, thread=thread, title=data["name"] or None, at=at)
|
||||||
|
|
||||||
|
|
||||||
|
@attrs_event
|
||||||
|
class UnfetchedThreadEvent(Event):
|
||||||
|
"""A message was received, but the data must be fetched manually.
|
||||||
|
|
||||||
|
Use `Message.fetch` to retrieve the message data.
|
||||||
|
|
||||||
|
This is usually used when somebody changes the group's photo, or when a new pending
|
||||||
|
group is created.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# TODO: Present this in a way that users can fetch the changed group photo easily
|
||||||
|
|
||||||
|
#: The thread the message was sent to
|
||||||
|
thread = attr.ib(type="_threads.ThreadABC")
|
||||||
|
#: The message
|
||||||
|
message = attr.ib(type=Optional["_models.Message"])
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _parse(cls, session, data):
|
||||||
|
thread = cls._get_thread(session, data)
|
||||||
|
message = None
|
||||||
|
if "messageId" in data:
|
||||||
|
message = _models.Message(thread=thread, id=data["messageId"])
|
||||||
|
return cls(thread=thread, message=message)
|
||||||
|
|
||||||
|
|
||||||
|
@attrs_event
|
||||||
|
class MessagesDelivered(ThreadEvent):
|
||||||
|
"""Somebody marked messages as delivered in a thread."""
|
||||||
|
|
||||||
|
#: The messages that were marked as delivered
|
||||||
|
messages = attr.ib(type=Sequence["_models.Message"])
|
||||||
|
#: When the messages were delivered
|
||||||
|
at = attr.ib(type=datetime.datetime)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _parse(cls, session, data):
|
||||||
|
thread = cls._get_thread(session, data)
|
||||||
|
if "actorFbId" in data:
|
||||||
|
author = _threads.User(session=session, id=data["actorFbId"])
|
||||||
|
else:
|
||||||
|
author = thread
|
||||||
|
messages = [_models.Message(thread=thread, id=x) for x in data["messageIds"]]
|
||||||
|
at = _util.millis_to_datetime(int(data["deliveredWatermarkTimestampMs"]))
|
||||||
|
return cls(author=author, thread=thread, messages=messages, at=at)
|
||||||
|
|
||||||
|
|
||||||
|
@attrs_event
|
||||||
|
class ThreadsRead(Event):
|
||||||
|
"""Somebody marked threads as read/seen."""
|
||||||
|
|
||||||
|
#: The person who marked the threads as read
|
||||||
|
author = attr.ib(type="_threads.ThreadABC")
|
||||||
|
#: The threads that were marked as read
|
||||||
|
threads = attr.ib(type=Sequence["_threads.ThreadABC"])
|
||||||
|
#: When the threads were read
|
||||||
|
at = attr.ib(type=datetime.datetime)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _parse_read_receipt(cls, session, data):
|
||||||
|
author = _threads.User(session=session, id=data["actorFbId"])
|
||||||
|
thread = cls._get_thread(session, data)
|
||||||
|
at = _util.millis_to_datetime(int(data["actionTimestampMs"]))
|
||||||
|
return cls(author=author, threads=[thread], at=at)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _parse(cls, session, data):
|
||||||
|
threads = [
|
||||||
|
cls._get_thread(session, {"threadKey": x}) for x in data["threadKeys"]
|
||||||
|
]
|
||||||
|
at = _util.millis_to_datetime(int(data["actionTimestamp"]))
|
||||||
|
return cls(author=session.user, threads=threads, at=at)
|
||||||
|
|
||||||
|
|
||||||
|
@attrs_event
|
||||||
|
class MessageEvent(ThreadEvent):
|
||||||
|
"""Somebody sent a message to a thread."""
|
||||||
|
|
||||||
|
#: The sent message
|
||||||
|
message = attr.ib(type="_models.Message")
|
||||||
|
#: When the threads were read
|
||||||
|
at = attr.ib(type=datetime.datetime)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _parse(cls, session, data):
|
||||||
|
author, thread, at = cls._parse_metadata(session, data)
|
||||||
|
message = _models.MessageData._from_pull(
|
||||||
|
thread, data, author=author.id, created_at=at,
|
||||||
|
)
|
||||||
|
return cls(author=author, thread=thread, message=message, at=at)
|
||||||
|
|
||||||
|
|
||||||
|
@attrs_event
|
||||||
|
class ThreadFolder(Event):
|
||||||
|
"""A thread was created in a folder.
|
||||||
|
|
||||||
|
Somebody that isn't connected with you on either Facebook or Messenger sends a
|
||||||
|
message. After that, you need to use `ThreadABC.fetch_messages` to actually read it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# TODO: Finish this
|
||||||
|
|
||||||
|
#: The created thread
|
||||||
|
thread = attr.ib(type="_threads.ThreadABC")
|
||||||
|
#: The folder/location
|
||||||
|
folder = attr.ib(type="_models.ThreadLocation")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _parse(cls, session, data):
|
||||||
|
thread = cls._get_thread(session, data)
|
||||||
|
folder = _models.ThreadLocation._parse(data["folder"])
|
||||||
|
return cls(thread=thread, folder=folder)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_delta(session, data):
|
||||||
|
class_ = data["class"]
|
||||||
|
if class_ == "AdminTextMessage":
|
||||||
|
return _delta_type.parse_admin_message(session, data)
|
||||||
|
elif class_ == "ParticipantsAddedToGroupThread":
|
||||||
|
return PeopleAdded._parse(session, data)
|
||||||
|
elif class_ == "ParticipantLeftGroupThread":
|
||||||
|
return PersonRemoved._parse(session, data)
|
||||||
|
elif class_ == "MarkFolderSeen":
|
||||||
|
# TODO: Finish this
|
||||||
|
folders = [_models.ThreadLocation._parse(folder) for folder in data["folders"]]
|
||||||
|
at = _util.millis_to_datetime(int(data["timestamp"]))
|
||||||
|
return None
|
||||||
|
elif class_ == "ThreadName":
|
||||||
|
return TitleSet._parse(session, data)
|
||||||
|
elif class_ == "ForcedFetch":
|
||||||
|
return UnfetchedThreadEvent._parse(session, data)
|
||||||
|
elif class_ == "DeliveryReceipt":
|
||||||
|
return MessagesDelivered._parse(session, data)
|
||||||
|
elif class_ == "ReadReceipt":
|
||||||
|
return ThreadsRead._parse_read_receipt(session, data)
|
||||||
|
elif class_ == "MarkRead":
|
||||||
|
return ThreadsRead._parse(session, data)
|
||||||
|
elif class_ == "NoOp":
|
||||||
|
# Skip "no operation" events
|
||||||
|
return None
|
||||||
|
elif class_ == "NewMessage":
|
||||||
|
return MessageEvent._parse(session, data)
|
||||||
|
elif class_ == "ThreadFolder":
|
||||||
|
return ThreadFolder._parse(session, data)
|
||||||
|
elif class_ == "ClientPayload":
|
||||||
|
raise ValueError("This is implemented in `parse_events`")
|
||||||
|
return UnknownEvent(source="Delta class", data=data)
|
||||||
331
fbchat/_events/_delta_type.py
Normal file
331
fbchat/_events/_delta_type.py
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
import attr
|
||||||
|
import datetime
|
||||||
|
from ._common import attrs_event, Event, UnknownEvent, ThreadEvent
|
||||||
|
from .. import _util, _threads, _models
|
||||||
|
|
||||||
|
from typing import Sequence, Optional
|
||||||
|
|
||||||
|
|
||||||
|
@attrs_event
|
||||||
|
class ColorSet(ThreadEvent):
|
||||||
|
"""Somebody set the color in a thread."""
|
||||||
|
|
||||||
|
#: The new color. Not limited to the ones in `ThreadABC.set_color`
|
||||||
|
color = attr.ib(type=str)
|
||||||
|
#: When the color was set
|
||||||
|
at = attr.ib(type=datetime.datetime)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _parse(cls, session, data):
|
||||||
|
author, thread, at = cls._parse_metadata(session, data)
|
||||||
|
color = _threads.ThreadABC._parse_color(data["untypedData"]["theme_color"])
|
||||||
|
return cls(author=author, thread=thread, color=color, at=at)
|
||||||
|
|
||||||
|
|
||||||
|
@attrs_event
|
||||||
|
class EmojiSet(ThreadEvent):
|
||||||
|
"""Somebody set the emoji in a thread."""
|
||||||
|
|
||||||
|
#: The new emoji
|
||||||
|
emoji = attr.ib(type=str)
|
||||||
|
#: When the emoji was set
|
||||||
|
at = attr.ib(type=datetime.datetime)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _parse(cls, session, data):
|
||||||
|
author, thread, at = cls._parse_metadata(session, data)
|
||||||
|
emoji = data["untypedData"]["thread_icon"]
|
||||||
|
return cls(author=author, thread=thread, emoji=emoji, at=at)
|
||||||
|
|
||||||
|
|
||||||
|
@attrs_event
|
||||||
|
class NicknameSet(ThreadEvent):
|
||||||
|
"""Somebody set the nickname of a person in a thread."""
|
||||||
|
|
||||||
|
#: The person whose nickname was set
|
||||||
|
subject = attr.ib(type=str)
|
||||||
|
#: The new nickname. If ``None``, the nickname was cleared
|
||||||
|
nickname = attr.ib(type=Optional[str])
|
||||||
|
#: When the nickname was set
|
||||||
|
at = attr.ib(type=datetime.datetime)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _parse(cls, session, data):
|
||||||
|
author, thread, at = cls._parse_metadata(session, data)
|
||||||
|
subject = _threads.User(
|
||||||
|
session=session, id=data["untypedData"]["participant_id"]
|
||||||
|
)
|
||||||
|
nickname = data["untypedData"]["nickname"] or None # None if ""
|
||||||
|
return cls(
|
||||||
|
author=author, thread=thread, subject=subject, nickname=nickname, at=at
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@attrs_event
|
||||||
|
class AdminsAdded(ThreadEvent):
|
||||||
|
"""Somebody added admins to a group."""
|
||||||
|
|
||||||
|
#: The people that were set as admins
|
||||||
|
added = attr.ib(type=Sequence["_threads.User"])
|
||||||
|
#: When the admins were added
|
||||||
|
at = attr.ib(type=datetime.datetime)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _parse(cls, session, data):
|
||||||
|
author, thread, at = cls._parse_metadata(session, data)
|
||||||
|
subject = _threads.User(session=session, id=data["untypedData"]["TARGET_ID"])
|
||||||
|
return cls(author=author, thread=thread, added=[subject], at=at)
|
||||||
|
|
||||||
|
|
||||||
|
@attrs_event
|
||||||
|
class AdminsRemoved(ThreadEvent):
|
||||||
|
"""Somebody removed admins from a group."""
|
||||||
|
|
||||||
|
#: The people that were removed as admins
|
||||||
|
removed = attr.ib(type=Sequence["_threads.User"])
|
||||||
|
#: When the admins were removed
|
||||||
|
at = attr.ib(type=datetime.datetime)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _parse(cls, session, data):
|
||||||
|
author, thread, at = cls._parse_metadata(session, data)
|
||||||
|
subject = _threads.User(session=session, id=data["untypedData"]["TARGET_ID"])
|
||||||
|
return cls(author=author, thread=thread, removed=[subject], at=at)
|
||||||
|
|
||||||
|
|
||||||
|
@attrs_event
|
||||||
|
class ApprovalModeSet(ThreadEvent):
|
||||||
|
"""Somebody changed the approval mode in a group."""
|
||||||
|
|
||||||
|
require_admin_approval = attr.ib(type=bool)
|
||||||
|
#: When the approval mode was set
|
||||||
|
at = attr.ib(type=datetime.datetime)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _parse(cls, session, data):
|
||||||
|
author, thread, at = cls._parse_metadata(session, data)
|
||||||
|
raa = data["untypedData"]["APPROVAL_MODE"] == "1"
|
||||||
|
return cls(author=author, thread=thread, require_admin_approval=raa, at=at)
|
||||||
|
|
||||||
|
|
||||||
|
@attrs_event
|
||||||
|
class CallStarted(ThreadEvent):
|
||||||
|
"""Somebody started a call."""
|
||||||
|
|
||||||
|
#: When the call was started
|
||||||
|
at = attr.ib(type=datetime.datetime)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _parse(cls, session, data):
|
||||||
|
author, thread, at = cls._parse_metadata(session, data)
|
||||||
|
return cls(author=author, thread=thread, at=at)
|
||||||
|
|
||||||
|
|
||||||
|
@attrs_event
|
||||||
|
class CallEnded(ThreadEvent):
|
||||||
|
"""Somebody ended a call."""
|
||||||
|
|
||||||
|
#: How long the call took
|
||||||
|
duration = attr.ib(type=datetime.timedelta)
|
||||||
|
#: When the call ended
|
||||||
|
at = attr.ib(type=datetime.datetime)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _parse(cls, session, data):
|
||||||
|
author, thread, at = cls._parse_metadata(session, data)
|
||||||
|
duration = _util.seconds_to_timedelta(int(data["untypedData"]["call_duration"]))
|
||||||
|
return cls(author=author, thread=thread, duration=duration, at=at)
|
||||||
|
|
||||||
|
|
||||||
|
@attrs_event
|
||||||
|
class CallJoined(ThreadEvent):
|
||||||
|
"""Somebody joined a call."""
|
||||||
|
|
||||||
|
#: When the call ended
|
||||||
|
at = attr.ib(type=datetime.datetime)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _parse(cls, session, data):
|
||||||
|
author, thread, at = cls._parse_metadata(session, data)
|
||||||
|
return cls(author=author, thread=thread, at=at)
|
||||||
|
|
||||||
|
|
||||||
|
@attrs_event
|
||||||
|
class PollCreated(ThreadEvent):
|
||||||
|
"""Somebody created a group poll."""
|
||||||
|
|
||||||
|
#: The new poll
|
||||||
|
poll = attr.ib(type="_models.Poll")
|
||||||
|
#: When the poll was created
|
||||||
|
at = attr.ib(type=datetime.datetime)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _parse(cls, session, data):
|
||||||
|
author, thread, at = cls._parse_metadata(session, data)
|
||||||
|
poll_data = _util.parse_json(data["untypedData"]["question_json"])
|
||||||
|
poll = _models.Poll._from_graphql(session, poll_data)
|
||||||
|
return cls(author=author, thread=thread, poll=poll, at=at)
|
||||||
|
|
||||||
|
|
||||||
|
@attrs_event
|
||||||
|
class PollVoted(ThreadEvent):
|
||||||
|
"""Somebody voted in a group poll."""
|
||||||
|
|
||||||
|
#: The updated poll
|
||||||
|
poll = attr.ib(type="_models.Poll")
|
||||||
|
#: Ids of the voted options
|
||||||
|
added_ids = attr.ib(type=Sequence[str])
|
||||||
|
#: Ids of the un-voted options
|
||||||
|
removed_ids = attr.ib(type=Sequence[str])
|
||||||
|
#: When the poll was voted in
|
||||||
|
at = attr.ib(type=datetime.datetime)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _parse(cls, session, data):
|
||||||
|
author, thread, at = cls._parse_metadata(session, data)
|
||||||
|
poll_data = _util.parse_json(data["untypedData"]["question_json"])
|
||||||
|
poll = _models.Poll._from_graphql(session, poll_data)
|
||||||
|
added_ids = _util.parse_json(data["untypedData"]["added_option_ids"])
|
||||||
|
removed_ids = _util.parse_json(data["untypedData"]["removed_option_ids"])
|
||||||
|
return cls(
|
||||||
|
author=author,
|
||||||
|
thread=thread,
|
||||||
|
poll=poll,
|
||||||
|
added_ids=[str(x) for x in added_ids],
|
||||||
|
removed_ids=[str(x) for x in removed_ids],
|
||||||
|
at=at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@attrs_event
|
||||||
|
class PlanCreated(ThreadEvent):
|
||||||
|
"""Somebody created a plan in a group."""
|
||||||
|
|
||||||
|
#: The new plan
|
||||||
|
plan = attr.ib(type="_models.PlanData")
|
||||||
|
#: When the plan was created
|
||||||
|
at = attr.ib(type=datetime.datetime)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _parse(cls, session, data):
|
||||||
|
author, thread, at = cls._parse_metadata(session, data)
|
||||||
|
plan = _models.PlanData._from_pull(session, data["untypedData"])
|
||||||
|
return cls(author=author, thread=thread, plan=plan, at=at)
|
||||||
|
|
||||||
|
|
||||||
|
@attrs_event
|
||||||
|
class PlanEnded(ThreadEvent):
|
||||||
|
"""A plan ended."""
|
||||||
|
|
||||||
|
#: The ended plan
|
||||||
|
plan = attr.ib(type="_models.PlanData")
|
||||||
|
#: When the plan ended
|
||||||
|
at = attr.ib(type=datetime.datetime)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _parse(cls, session, data):
|
||||||
|
author, thread, at = cls._parse_metadata(session, data)
|
||||||
|
plan = _models.PlanData._from_pull(session, data["untypedData"])
|
||||||
|
return cls(author=author, thread=thread, plan=plan, at=at)
|
||||||
|
|
||||||
|
|
||||||
|
@attrs_event
|
||||||
|
class PlanEdited(ThreadEvent):
|
||||||
|
"""Somebody changed a plan in a group."""
|
||||||
|
|
||||||
|
#: The updated plan
|
||||||
|
plan = attr.ib(type="_models.PlanData")
|
||||||
|
#: When the plan was updated
|
||||||
|
at = attr.ib(type=datetime.datetime)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _parse(cls, session, data):
|
||||||
|
author, thread, at = cls._parse_metadata(session, data)
|
||||||
|
plan = _models.PlanData._from_pull(session, data["untypedData"])
|
||||||
|
return cls(author=author, thread=thread, plan=plan, at=at)
|
||||||
|
|
||||||
|
|
||||||
|
@attrs_event
|
||||||
|
class PlanDeleted(ThreadEvent):
|
||||||
|
"""Somebody removed a plan in a group."""
|
||||||
|
|
||||||
|
#: The removed plan
|
||||||
|
plan = attr.ib(type="_models.PlanData")
|
||||||
|
#: When the plan was removed
|
||||||
|
at = attr.ib(type=datetime.datetime)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _parse(cls, session, data):
|
||||||
|
author, thread, at = cls._parse_metadata(session, data)
|
||||||
|
plan = _models.PlanData._from_pull(session, data["untypedData"])
|
||||||
|
return cls(author=author, thread=thread, plan=plan, at=at)
|
||||||
|
|
||||||
|
|
||||||
|
@attrs_event
|
||||||
|
class PlanResponded(ThreadEvent):
|
||||||
|
"""Somebody responded to a plan in a group."""
|
||||||
|
|
||||||
|
#: The plan that was responded to
|
||||||
|
plan = attr.ib(type="_models.PlanData")
|
||||||
|
#: Whether the author will go to the plan or not
|
||||||
|
take_part = attr.ib(type=bool)
|
||||||
|
#: When the plan was removed
|
||||||
|
at = attr.ib(type=datetime.datetime)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _parse(cls, session, data):
|
||||||
|
author, thread, at = cls._parse_metadata(session, data)
|
||||||
|
plan = _models.PlanData._from_pull(session, data["untypedData"])
|
||||||
|
take_part = data["untypedData"]["guest_status"] == "GOING"
|
||||||
|
return cls(author=author, thread=thread, plan=plan, take_part=take_part, at=at)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_admin_message(session, data):
|
||||||
|
type_ = data["type"]
|
||||||
|
if type_ == "change_thread_theme":
|
||||||
|
return ColorSet._parse(session, data)
|
||||||
|
elif type_ == "change_thread_icon":
|
||||||
|
return EmojiSet._parse(session, data)
|
||||||
|
elif type_ == "change_thread_nickname":
|
||||||
|
return NicknameSet._parse(session, data)
|
||||||
|
elif type_ == "change_thread_admins":
|
||||||
|
event_type = data["untypedData"]["ADMIN_EVENT"]
|
||||||
|
if event_type == "add_admin":
|
||||||
|
return AdminsAdded._parse(session, data)
|
||||||
|
elif event_type == "remove_admin":
|
||||||
|
return AdminsRemoved._parse(session, data)
|
||||||
|
else:
|
||||||
|
pass
|
||||||
|
elif type_ == "change_thread_approval_mode":
|
||||||
|
return ApprovalModeSet._parse(session, data)
|
||||||
|
elif type_ == "instant_game_update":
|
||||||
|
pass # TODO: This
|
||||||
|
elif type_ == "messenger_call_log": # Previously "rtc_call_log"
|
||||||
|
event_type = data["untypedData"]["event"]
|
||||||
|
if event_type == "group_call_started":
|
||||||
|
return CallStarted._parse(session, data)
|
||||||
|
elif event_type in ["group_call_ended", "one_on_one_call_ended"]:
|
||||||
|
return CallEnded._parse(session, data)
|
||||||
|
else:
|
||||||
|
pass
|
||||||
|
elif type_ == "participant_joined_group_call":
|
||||||
|
return CallJoined._parse(session, data)
|
||||||
|
elif type_ == "group_poll":
|
||||||
|
event_type = data["untypedData"]["event_type"]
|
||||||
|
if event_type == "question_creation":
|
||||||
|
return PollCreated._parse(session, data)
|
||||||
|
elif event_type == "update_vote":
|
||||||
|
return PollVoted._parse(session, data)
|
||||||
|
else:
|
||||||
|
pass
|
||||||
|
elif type_ == "lightweight_event_create":
|
||||||
|
return PlanCreated._parse(session, data)
|
||||||
|
elif type_ == "lightweight_event_notify":
|
||||||
|
return PlanEnded._parse(session, data)
|
||||||
|
elif type_ == "lightweight_event_update":
|
||||||
|
return PlanEdited._parse(session, data)
|
||||||
|
elif type_ == "lightweight_event_delete":
|
||||||
|
return PlanDeleted._parse(session, data)
|
||||||
|
elif type_ == "lightweight_event_rsvp":
|
||||||
|
return PlanResponded._parse(session, data)
|
||||||
|
return UnknownEvent(source="Delta type", data=data)
|
||||||
@@ -1,34 +1,88 @@
|
|||||||
# -*- coding: UTF-8 -*-
|
import attr
|
||||||
from __future__ import unicode_literals
|
import requests
|
||||||
|
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
# Not frozen, since that doesn't work in PyPy
|
||||||
|
@attr.s(slots=True, auto_exc=True)
|
||||||
|
class FacebookError(Exception):
|
||||||
|
"""Base class for all custom exceptions raised by ``fbchat``.
|
||||||
|
|
||||||
|
All exceptions in the module inherit this.
|
||||||
|
"""
|
||||||
|
|
||||||
|
#: A message describing the error
|
||||||
|
message = attr.ib(type=str)
|
||||||
|
|
||||||
|
|
||||||
class FBchatException(Exception):
|
@attr.s(slots=True, auto_exc=True)
|
||||||
"""Custom exception thrown by fbchat. All exceptions in the fbchat module inherits this"""
|
class HTTPError(FacebookError):
|
||||||
|
"""Base class for errors with the HTTP(s) connection to Facebook."""
|
||||||
|
|
||||||
|
#: The returned HTTP status code, if relevant
|
||||||
|
status_code = attr.ib(None, type=Optional[int])
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
if not self.status_code:
|
||||||
|
return self.message
|
||||||
|
return "Got {} response: {}".format(self.status_code, self.message)
|
||||||
|
|
||||||
|
|
||||||
class FBchatFacebookError(FBchatException):
|
@attr.s(slots=True, auto_exc=True)
|
||||||
|
class ParseError(FacebookError):
|
||||||
|
"""Raised when we fail parsing a response from Facebook.
|
||||||
|
|
||||||
|
This may contain sensitive data, so should not be logged to file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
data = attr.ib(type=Any)
|
||||||
|
"""The data that triggered the error.
|
||||||
|
|
||||||
|
The format of this cannot be relied on, it's only for debugging purposes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
msg = "{}. Please report this, along with the data below!\n{}"
|
||||||
|
return msg.format(self.message, self.data)
|
||||||
|
|
||||||
|
|
||||||
|
@attr.s(slots=True, auto_exc=True)
|
||||||
|
class NotLoggedIn(FacebookError):
|
||||||
|
"""Raised by Facebook if the client has been logged out."""
|
||||||
|
|
||||||
|
|
||||||
|
@attr.s(slots=True, auto_exc=True)
|
||||||
|
class ExternalError(FacebookError):
|
||||||
|
"""Base class for errors that Facebook return."""
|
||||||
|
|
||||||
|
#: The error message that Facebook returned (Possibly in the user's own language)
|
||||||
|
description = attr.ib(type=str)
|
||||||
#: The error code that Facebook returned
|
#: The error code that Facebook returned
|
||||||
fb_error_code = None
|
code = attr.ib(None, type=Optional[int])
|
||||||
#: The error message that Facebook returned (In the user's own language)
|
|
||||||
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)
|
|
||||||
request_status_code = None
|
|
||||||
|
|
||||||
def __init__(
|
def __str__(self):
|
||||||
self,
|
if self.code:
|
||||||
message,
|
return "#{} {}: {}".format(self.code, self.message, self.description)
|
||||||
fb_error_code=None,
|
return "{}: {}".format(self.message, self.description)
|
||||||
fb_error_message=None,
|
|
||||||
request_status_code=None,
|
|
||||||
):
|
|
||||||
super(FBchatFacebookError, self).__init__(message)
|
|
||||||
"""Thrown by fbchat when Facebook returns an error"""
|
|
||||||
self.fb_error_code = str(fb_error_code)
|
|
||||||
self.fb_error_message = fb_error_message
|
|
||||||
self.request_status_code = request_status_code
|
|
||||||
|
|
||||||
|
|
||||||
class FBchatInvalidParameters(FBchatFacebookError):
|
@attr.s(slots=True, auto_exc=True)
|
||||||
|
class GraphQLError(ExternalError):
|
||||||
|
"""Raised by Facebook if there was an error in the GraphQL query."""
|
||||||
|
|
||||||
|
# TODO: Handle multiple errors
|
||||||
|
|
||||||
|
#: Query debug information
|
||||||
|
debug_info = attr.ib(None, type=Optional[str])
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
if self.debug_info:
|
||||||
|
return "{}, {}".format(super().__str__(), self.debug_info)
|
||||||
|
return super().__str__()
|
||||||
|
|
||||||
|
|
||||||
|
@attr.s(slots=True, auto_exc=True)
|
||||||
|
class InvalidParameters(ExternalError):
|
||||||
"""Raised by Facebook if:
|
"""Raised by Facebook if:
|
||||||
|
|
||||||
- Some function supplied invalid parameters.
|
- Some function supplied invalid parameters.
|
||||||
@@ -37,21 +91,75 @@ class FBchatInvalidParameters(FBchatFacebookError):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
class FBchatNotLoggedIn(FBchatFacebookError):
|
@attr.s(slots=True, auto_exc=True)
|
||||||
"""Raised by Facebook if the client has been logged out."""
|
class PleaseRefresh(ExternalError):
|
||||||
|
|
||||||
fb_error_code = "1357001"
|
|
||||||
|
|
||||||
|
|
||||||
class FBchatPleaseRefresh(FBchatFacebookError):
|
|
||||||
"""Raised by Facebook if the client has been inactive for too long.
|
"""Raised by Facebook if the client has been inactive for too long.
|
||||||
|
|
||||||
This error usually happens after 1-2 days of inactivity.
|
This error usually happens after 1-2 days of inactivity.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
fb_error_code = "1357004"
|
code = attr.ib(1357004)
|
||||||
fb_error_message = "Please try closing and re-opening your browser window."
|
|
||||||
|
|
||||||
|
|
||||||
class FBchatUserError(FBchatException):
|
def handle_payload_error(j):
|
||||||
"""Thrown by fbchat when wrong values are entered"""
|
if "error" not in j:
|
||||||
|
return
|
||||||
|
code = j["error"]
|
||||||
|
if code == 1357001:
|
||||||
|
raise NotLoggedIn(j["errorSummary"])
|
||||||
|
elif code == 1357004:
|
||||||
|
error_cls = PleaseRefresh
|
||||||
|
elif code in (1357031, 1545010, 1545003):
|
||||||
|
error_cls = InvalidParameters
|
||||||
|
else:
|
||||||
|
error_cls = ExternalError
|
||||||
|
raise error_cls(j["errorSummary"], description=j["errorDescription"], code=code)
|
||||||
|
|
||||||
|
|
||||||
|
def handle_graphql_errors(j):
|
||||||
|
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 `severity`
|
||||||
|
raise GraphQLError(
|
||||||
|
# TODO: What data is always available?
|
||||||
|
message=error.get("summary", "Unknown error"),
|
||||||
|
description=error.get("message") or error.get("description") or "",
|
||||||
|
code=error.get("code"),
|
||||||
|
debug_info=error.get("debug_info"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def handle_http_error(code):
|
||||||
|
if code == 404:
|
||||||
|
raise HTTPError(
|
||||||
|
"This might be because you provided an invalid id"
|
||||||
|
+ " (Facebook usually require integer ids)",
|
||||||
|
status_code=code,
|
||||||
|
)
|
||||||
|
if code == 500:
|
||||||
|
raise HTTPError(
|
||||||
|
"There is probably an error on the endpoint, or it might be rate limited",
|
||||||
|
status_code=code,
|
||||||
|
)
|
||||||
|
if 400 <= code < 600:
|
||||||
|
raise HTTPError("Failed sending request", status_code=code)
|
||||||
|
|
||||||
|
|
||||||
|
def handle_requests_error(e):
|
||||||
|
if isinstance(e, requests.ConnectionError):
|
||||||
|
raise HTTPError("Connection error") from e
|
||||||
|
if isinstance(e, requests.HTTPError):
|
||||||
|
pass # Raised when using .raise_for_status, so should never happen
|
||||||
|
if isinstance(e, requests.URLRequired):
|
||||||
|
pass # Should never happen, we always prove valid URLs
|
||||||
|
if isinstance(e, requests.TooManyRedirects):
|
||||||
|
pass # TODO: Consider using allow_redirects=False to prevent this
|
||||||
|
if isinstance(e, requests.Timeout):
|
||||||
|
pass # Should never happen, we don't set timeouts
|
||||||
|
|
||||||
|
raise HTTPError("Requests error") from e
|
||||||
|
|||||||
277
fbchat/_file.py
277
fbchat/_file.py
@@ -1,277 +0,0 @@
|
|||||||
# -*- coding: UTF-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
import attr
|
|
||||||
from ._attachment import Attachment
|
|
||||||
|
|
||||||
|
|
||||||
@attr.s(cmp=False)
|
|
||||||
class FileAttachment(Attachment):
|
|
||||||
"""Represents a file that has been sent as a Facebook attachment"""
|
|
||||||
|
|
||||||
#: Url where you can download the file
|
|
||||||
url = attr.ib(None)
|
|
||||||
#: Size of the file in bytes
|
|
||||||
size = attr.ib(None)
|
|
||||||
#: Name of the file
|
|
||||||
name = attr.ib(None)
|
|
||||||
#: Whether Facebook determines that this file may be harmful
|
|
||||||
is_malicious = attr.ib(None)
|
|
||||||
|
|
||||||
# Put here for backwards compatibility, so that the init argument order is preserved
|
|
||||||
uid = attr.ib(None)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _from_graphql(cls, data):
|
|
||||||
return cls(
|
|
||||||
url=data.get("url"),
|
|
||||||
name=data.get("filename"),
|
|
||||||
is_malicious=data.get("is_malicious"),
|
|
||||||
uid=data.get("message_file_fbid"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@attr.s(cmp=False)
|
|
||||||
class AudioAttachment(Attachment):
|
|
||||||
"""Represents an audio file that has been sent as a Facebook attachment"""
|
|
||||||
|
|
||||||
#: Name of the file
|
|
||||||
filename = attr.ib(None)
|
|
||||||
#: Url of the audio file
|
|
||||||
url = attr.ib(None)
|
|
||||||
#: Duration of the audioclip in milliseconds
|
|
||||||
duration = attr.ib(None)
|
|
||||||
#: Audio type
|
|
||||||
audio_type = attr.ib(None)
|
|
||||||
|
|
||||||
# Put here for backwards compatibility, so that the init argument order is preserved
|
|
||||||
uid = attr.ib(None)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _from_graphql(cls, data):
|
|
||||||
return cls(
|
|
||||||
filename=data.get("filename"),
|
|
||||||
url=data.get("playable_url"),
|
|
||||||
duration=data.get("playable_duration_in_ms"),
|
|
||||||
audio_type=data.get("audio_type"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@attr.s(cmp=False, init=False)
|
|
||||||
class ImageAttachment(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
|
|
||||||
it the uid of the image attachment
|
|
||||||
"""
|
|
||||||
|
|
||||||
#: The extension of the original image (eg. 'png')
|
|
||||||
original_extension = attr.ib(None)
|
|
||||||
#: Width of original image
|
|
||||||
width = attr.ib(None, converter=lambda x: None if x is None else int(x))
|
|
||||||
#: Height of original image
|
|
||||||
height = attr.ib(None, converter=lambda x: None if x is None else int(x))
|
|
||||||
|
|
||||||
#: Whether the image is animated
|
|
||||||
is_animated = attr.ib(None)
|
|
||||||
|
|
||||||
#: URL to a thumbnail of the image
|
|
||||||
thumbnail_url = attr.ib(None)
|
|
||||||
|
|
||||||
#: URL to a medium preview of the image
|
|
||||||
preview_url = attr.ib(None)
|
|
||||||
#: Width of the medium preview image
|
|
||||||
preview_width = attr.ib(None)
|
|
||||||
#: Height of the medium preview image
|
|
||||||
preview_height = attr.ib(None)
|
|
||||||
|
|
||||||
#: URL to a large preview of the image
|
|
||||||
large_preview_url = attr.ib(None)
|
|
||||||
#: Width of the large preview image
|
|
||||||
large_preview_width = attr.ib(None)
|
|
||||||
#: Height of the large preview image
|
|
||||||
large_preview_height = attr.ib(None)
|
|
||||||
|
|
||||||
#: URL to an animated preview of the image (eg. for gifs)
|
|
||||||
animated_preview_url = attr.ib(None)
|
|
||||||
#: Width of the animated preview image
|
|
||||||
animated_preview_width = attr.ib(None)
|
|
||||||
#: Height of the animated preview image
|
|
||||||
animated_preview_height = attr.ib(None)
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
original_extension=None,
|
|
||||||
width=None,
|
|
||||||
height=None,
|
|
||||||
is_animated=None,
|
|
||||||
thumbnail_url=None,
|
|
||||||
preview=None,
|
|
||||||
large_preview=None,
|
|
||||||
animated_preview=None,
|
|
||||||
**kwargs
|
|
||||||
):
|
|
||||||
super(ImageAttachment, self).__init__(**kwargs)
|
|
||||||
self.original_extension = original_extension
|
|
||||||
if width is not None:
|
|
||||||
width = int(width)
|
|
||||||
self.width = width
|
|
||||||
if height is not None:
|
|
||||||
height = int(height)
|
|
||||||
self.height = height
|
|
||||||
self.is_animated = is_animated
|
|
||||||
self.thumbnail_url = thumbnail_url
|
|
||||||
|
|
||||||
if preview is None:
|
|
||||||
preview = {}
|
|
||||||
self.preview_url = preview.get("uri")
|
|
||||||
self.preview_width = preview.get("width")
|
|
||||||
self.preview_height = preview.get("height")
|
|
||||||
|
|
||||||
if large_preview is None:
|
|
||||||
large_preview = {}
|
|
||||||
self.large_preview_url = large_preview.get("uri")
|
|
||||||
self.large_preview_width = large_preview.get("width")
|
|
||||||
self.large_preview_height = large_preview.get("height")
|
|
||||||
|
|
||||||
if animated_preview is None:
|
|
||||||
animated_preview = {}
|
|
||||||
self.animated_preview_url = animated_preview.get("uri")
|
|
||||||
self.animated_preview_width = animated_preview.get("width")
|
|
||||||
self.animated_preview_height = animated_preview.get("height")
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _from_graphql(cls, data):
|
|
||||||
return cls(
|
|
||||||
original_extension=data.get("original_extension")
|
|
||||||
or (data["filename"].split("-")[0] if data.get("filename") else None),
|
|
||||||
width=data.get("original_dimensions", {}).get("width"),
|
|
||||||
height=data.get("original_dimensions", {}).get("height"),
|
|
||||||
is_animated=data["__typename"] == "MessageAnimatedImage",
|
|
||||||
thumbnail_url=data.get("thumbnail", {}).get("uri"),
|
|
||||||
preview=data.get("preview") or data.get("preview_image"),
|
|
||||||
large_preview=data.get("large_preview"),
|
|
||||||
animated_preview=data.get("animated_image"),
|
|
||||||
uid=data.get("legacy_attachment_id"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@attr.s(cmp=False, init=False)
|
|
||||||
class VideoAttachment(Attachment):
|
|
||||||
"""Represents a video that has been sent as a Facebook attachment"""
|
|
||||||
|
|
||||||
#: Size of the original video in bytes
|
|
||||||
size = attr.ib(None)
|
|
||||||
#: Width of original video
|
|
||||||
width = attr.ib(None)
|
|
||||||
#: Height of original video
|
|
||||||
height = attr.ib(None)
|
|
||||||
#: Length of video in milliseconds
|
|
||||||
duration = attr.ib(None)
|
|
||||||
#: URL to very compressed preview video
|
|
||||||
preview_url = attr.ib(None)
|
|
||||||
|
|
||||||
#: URL to a small preview image of the video
|
|
||||||
small_image_url = attr.ib(None)
|
|
||||||
#: Width of the small preview image
|
|
||||||
small_image_width = attr.ib(None)
|
|
||||||
#: Height of the small preview image
|
|
||||||
small_image_height = attr.ib(None)
|
|
||||||
|
|
||||||
#: URL to a medium preview image of the video
|
|
||||||
medium_image_url = attr.ib(None)
|
|
||||||
#: Width of the medium preview image
|
|
||||||
medium_image_width = attr.ib(None)
|
|
||||||
#: Height of the medium preview image
|
|
||||||
medium_image_height = attr.ib(None)
|
|
||||||
|
|
||||||
#: URL to a large preview image of the video
|
|
||||||
large_image_url = attr.ib(None)
|
|
||||||
#: Width of the large preview image
|
|
||||||
large_image_width = attr.ib(None)
|
|
||||||
#: Height of the large preview image
|
|
||||||
large_image_height = attr.ib(None)
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
size=None,
|
|
||||||
width=None,
|
|
||||||
height=None,
|
|
||||||
duration=None,
|
|
||||||
preview_url=None,
|
|
||||||
small_image=None,
|
|
||||||
medium_image=None,
|
|
||||||
large_image=None,
|
|
||||||
**kwargs
|
|
||||||
):
|
|
||||||
super(VideoAttachment, self).__init__(**kwargs)
|
|
||||||
self.size = size
|
|
||||||
self.width = width
|
|
||||||
self.height = height
|
|
||||||
self.duration = duration
|
|
||||||
self.preview_url = preview_url
|
|
||||||
|
|
||||||
if small_image is None:
|
|
||||||
small_image = {}
|
|
||||||
self.small_image_url = small_image.get("uri")
|
|
||||||
self.small_image_width = small_image.get("width")
|
|
||||||
self.small_image_height = small_image.get("height")
|
|
||||||
|
|
||||||
if medium_image is None:
|
|
||||||
medium_image = {}
|
|
||||||
self.medium_image_url = medium_image.get("uri")
|
|
||||||
self.medium_image_width = medium_image.get("width")
|
|
||||||
self.medium_image_height = medium_image.get("height")
|
|
||||||
|
|
||||||
if large_image is None:
|
|
||||||
large_image = {}
|
|
||||||
self.large_image_url = large_image.get("uri")
|
|
||||||
self.large_image_width = large_image.get("width")
|
|
||||||
self.large_image_height = large_image.get("height")
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _from_graphql(cls, data):
|
|
||||||
return cls(
|
|
||||||
width=data.get("original_dimensions", {}).get("width"),
|
|
||||||
height=data.get("original_dimensions", {}).get("height"),
|
|
||||||
duration=data.get("playable_duration_in_ms"),
|
|
||||||
preview_url=data.get("playable_url"),
|
|
||||||
small_image=data.get("chat_image"),
|
|
||||||
medium_image=data.get("inbox_image"),
|
|
||||||
large_image=data.get("large_image"),
|
|
||||||
uid=data.get("legacy_attachment_id"),
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _from_subattachment(cls, data):
|
|
||||||
media = data["media"]
|
|
||||||
return cls(
|
|
||||||
duration=media.get("playable_duration_in_ms"),
|
|
||||||
preview_url=media.get("playable_url"),
|
|
||||||
medium_image=media.get("image"),
|
|
||||||
uid=data["target"].get("video_id"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def graphql_to_attachment(data):
|
|
||||||
_type = data["__typename"]
|
|
||||||
if _type in ["MessageImage", "MessageAnimatedImage"]:
|
|
||||||
return ImageAttachment._from_graphql(data)
|
|
||||||
elif _type == "MessageVideo":
|
|
||||||
return VideoAttachment._from_graphql(data)
|
|
||||||
elif _type == "MessageAudio":
|
|
||||||
return AudioAttachment._from_graphql(data)
|
|
||||||
elif _type == "MessageFile":
|
|
||||||
return FileAttachment._from_graphql(data)
|
|
||||||
|
|
||||||
return Attachment(uid=data.get("legacy_attachment_id"))
|
|
||||||
|
|
||||||
|
|
||||||
def graphql_to_subattachment(data):
|
|
||||||
target = data.get("target")
|
|
||||||
type_ = target.get("__typename") if target else None
|
|
||||||
|
|
||||||
if type_ == "Video":
|
|
||||||
return VideoAttachment._from_subattachment(data)
|
|
||||||
|
|
||||||
return None
|
|
||||||
45
fbchat/_fix_module_metadata.py
Normal file
45
fbchat/_fix_module_metadata.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
"""Everything in this module is taken from the excellent trio project.
|
||||||
|
|
||||||
|
Having the public path in .__module__ attributes is important for:
|
||||||
|
- exception names in printed tracebacks
|
||||||
|
- ~sphinx :show-inheritance:~
|
||||||
|
- deprecation warnings
|
||||||
|
- pickle
|
||||||
|
- probably other stuff
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
def fixup_module_metadata(namespace):
|
||||||
|
def fix_one(qualname, name, obj):
|
||||||
|
# Custom extension, to handle classmethods, staticmethods and properties
|
||||||
|
if isinstance(obj, (classmethod, staticmethod)):
|
||||||
|
obj = obj.__func__
|
||||||
|
if isinstance(obj, property):
|
||||||
|
obj = obj.fget
|
||||||
|
|
||||||
|
mod = getattr(obj, "__module__", None)
|
||||||
|
if mod is not None and mod.startswith("fbchat."):
|
||||||
|
obj.__module__ = "fbchat"
|
||||||
|
# Modules, unlike everything else in Python, put fully-qualitied
|
||||||
|
# names into their __name__ attribute. We check for "." to avoid
|
||||||
|
# rewriting these.
|
||||||
|
if hasattr(obj, "__name__") and "." not in obj.__name__:
|
||||||
|
obj.__name__ = name
|
||||||
|
obj.__qualname__ = qualname
|
||||||
|
if isinstance(obj, type):
|
||||||
|
# Fix methods
|
||||||
|
for attr_name, attr_value in obj.__dict__.items():
|
||||||
|
fix_one(objname + "." + attr_name, attr_name, attr_value)
|
||||||
|
|
||||||
|
for objname, obj in namespace.items():
|
||||||
|
if not objname.startswith("_"): # ignore private attributes
|
||||||
|
fix_one(objname, objname, obj)
|
||||||
|
|
||||||
|
|
||||||
|
# Allow disabling this when running Sphinx
|
||||||
|
# This is done so that Sphinx autodoc can detect the file's source
|
||||||
|
# TODO: Find a better way to detect when we're running Sphinx!
|
||||||
|
if os.environ.get("_FBCHAT_DISABLE_FIX_MODULE_METADATA") == "1":
|
||||||
|
fixup_module_metadata = lambda namespace: None
|
||||||
@@ -1,10 +1,7 @@
|
|||||||
# -*- coding: UTF-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
from . import _util
|
from ._common import log
|
||||||
from ._exception import FBchatException
|
from . import _util, _exception
|
||||||
|
|
||||||
# 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
|
||||||
@@ -34,30 +31,30 @@ def queries_to_json(*queries):
|
|||||||
rtn = {}
|
rtn = {}
|
||||||
for i, query in enumerate(queries):
|
for i, query in enumerate(queries):
|
||||||
rtn["q{}".format(i)] = query
|
rtn["q{}".format(i)] = query
|
||||||
return json.dumps(rtn)
|
return _util.json_minimal(rtn)
|
||||||
|
|
||||||
|
|
||||||
def response_to_json(content):
|
def response_to_json(text):
|
||||||
content = _util.strip_json_cruft(content) # Usually only needed in some error cases
|
text = _util.strip_json_cruft(text) # Usually only needed in some error cases
|
||||||
try:
|
try:
|
||||||
j = json.loads(content, cls=ConcatJSONDecoder)
|
j = json.loads(text, cls=ConcatJSONDecoder)
|
||||||
except Exception:
|
except Exception as e:
|
||||||
raise FBchatException("Error while parsing JSON: {}".format(repr(content)))
|
raise _exception.ParseError("Error while parsing JSON", data=text) from e
|
||||||
|
|
||||||
rtn = [None] * (len(j))
|
rtn = [None] * (len(j))
|
||||||
for x in j:
|
for x in j:
|
||||||
if "error_results" in x:
|
if "error_results" in x:
|
||||||
del rtn[-1]
|
del rtn[-1]
|
||||||
continue
|
continue
|
||||||
_util.handle_payload_error(x)
|
_exception.handle_payload_error(x)
|
||||||
[(key, value)] = x.items()
|
[(key, value)] = x.items()
|
||||||
_util.handle_graphql_errors(value)
|
_exception.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:
|
||||||
rtn[int(key[1:])] = value["data"]
|
rtn[int(key[1:])] = value["data"]
|
||||||
|
|
||||||
_util.log.debug(rtn)
|
log.debug(rtn)
|
||||||
|
|
||||||
return rtn
|
return rtn
|
||||||
|
|
||||||
@@ -107,6 +104,7 @@ QueryFragment Group: MessageThread {
|
|||||||
all_participants {
|
all_participants {
|
||||||
nodes {
|
nodes {
|
||||||
messaging_actor {
|
messaging_actor {
|
||||||
|
__typename,
|
||||||
id
|
id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
118
fbchat/_group.py
118
fbchat/_group.py
@@ -1,118 +0,0 @@
|
|||||||
# -*- coding: UTF-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
import attr
|
|
||||||
from . import _plan
|
|
||||||
from ._thread import ThreadType, Thread
|
|
||||||
|
|
||||||
|
|
||||||
@attr.s(cmp=False, init=False)
|
|
||||||
class Group(Thread):
|
|
||||||
"""Represents a Facebook group. Inherits `Thread`"""
|
|
||||||
|
|
||||||
#: 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)
|
|
||||||
#: A dict, containing user nicknames mapped to their IDs
|
|
||||||
nicknames = attr.ib(factory=dict, converter=lambda x: {} if x is None else x)
|
|
||||||
#: A :class:`ThreadColor`. The groups's message color
|
|
||||||
color = attr.ib(None)
|
|
||||||
#: The groups's default emoji
|
|
||||||
emoji = attr.ib(None)
|
|
||||||
# Set containing user IDs of thread admins
|
|
||||||
admins = attr.ib(factory=set, converter=lambda x: set() if x is None else x)
|
|
||||||
# True if users need approval to join
|
|
||||||
approval_mode = attr.ib(None)
|
|
||||||
# Set containing user IDs requesting to join
|
|
||||||
approval_requests = attr.ib(
|
|
||||||
factory=set, converter=lambda x: set() if x is None else x
|
|
||||||
)
|
|
||||||
# Link for joining group
|
|
||||||
join_link = attr.ib(None)
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
uid,
|
|
||||||
participants=None,
|
|
||||||
nicknames=None,
|
|
||||||
color=None,
|
|
||||||
emoji=None,
|
|
||||||
admins=None,
|
|
||||||
approval_mode=None,
|
|
||||||
approval_requests=None,
|
|
||||||
join_link=None,
|
|
||||||
privacy_mode=None,
|
|
||||||
**kwargs
|
|
||||||
):
|
|
||||||
super(Group, self).__init__(ThreadType.GROUP, uid, **kwargs)
|
|
||||||
if participants is None:
|
|
||||||
participants = set()
|
|
||||||
self.participants = participants
|
|
||||||
if nicknames is None:
|
|
||||||
nicknames = []
|
|
||||||
self.nicknames = nicknames
|
|
||||||
self.color = color
|
|
||||||
self.emoji = emoji
|
|
||||||
if admins is None:
|
|
||||||
admins = set()
|
|
||||||
self.admins = admins
|
|
||||||
self.approval_mode = approval_mode
|
|
||||||
if approval_requests is None:
|
|
||||||
approval_requests = set()
|
|
||||||
self.approval_requests = approval_requests
|
|
||||||
self.join_link = join_link
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _from_graphql(cls, data):
|
|
||||||
if data.get("image") is None:
|
|
||||||
data["image"] = {}
|
|
||||||
c_info = cls._parse_customization_info(data)
|
|
||||||
last_message_timestamp = None
|
|
||||||
if "last_message" in data:
|
|
||||||
last_message_timestamp = data["last_message"]["nodes"][0][
|
|
||||||
"timestamp_precise"
|
|
||||||
]
|
|
||||||
plan = None
|
|
||||||
if data.get("event_reminders") and data["event_reminders"].get("nodes"):
|
|
||||||
plan = _plan.Plan._from_graphql(data["event_reminders"]["nodes"][0])
|
|
||||||
|
|
||||||
return cls(
|
|
||||||
data["thread_key"]["thread_fbid"],
|
|
||||||
participants=set(
|
|
||||||
[
|
|
||||||
node["messaging_actor"]["id"]
|
|
||||||
for node in data["all_participants"]["nodes"]
|
|
||||||
]
|
|
||||||
),
|
|
||||||
nicknames=c_info.get("nicknames"),
|
|
||||||
color=c_info.get("color"),
|
|
||||||
emoji=c_info.get("emoji"),
|
|
||||||
admins=set([node.get("id") for node in data.get("thread_admins")]),
|
|
||||||
approval_mode=bool(data.get("approval_mode"))
|
|
||||||
if data.get("approval_mode") is not None
|
|
||||||
else None,
|
|
||||||
approval_requests=set(
|
|
||||||
node["requester"]["id"]
|
|
||||||
for node in data["group_approval_queue"]["nodes"]
|
|
||||||
)
|
|
||||||
if data.get("group_approval_queue")
|
|
||||||
else None,
|
|
||||||
join_link=data["joinable_mode"].get("link"),
|
|
||||||
photo=data["image"].get("uri"),
|
|
||||||
name=data.get("name"),
|
|
||||||
message_count=data.get("messages_count"),
|
|
||||||
last_message_timestamp=last_message_timestamp,
|
|
||||||
plan=plan,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@attr.s(cmp=False, init=False)
|
|
||||||
class Room(Group):
|
|
||||||
"""Deprecated. Use :class:`Group` instead"""
|
|
||||||
|
|
||||||
# True is room is not discoverable
|
|
||||||
privacy_mode = attr.ib(None)
|
|
||||||
|
|
||||||
def __init__(self, uid, privacy_mode=None, **kwargs):
|
|
||||||
super(Room, self).__init__(uid, **kwargs)
|
|
||||||
self.type = ThreadType.ROOM
|
|
||||||
self.privacy_mode = privacy_mode
|
|
||||||
407
fbchat/_listen.py
Normal file
407
fbchat/_listen.py
Normal file
@@ -0,0 +1,407 @@
|
|||||||
|
import attr
|
||||||
|
import random
|
||||||
|
import paho.mqtt.client
|
||||||
|
import requests
|
||||||
|
from ._common import log, kw_only
|
||||||
|
from . import _util, _exception, _session, _graphql, _events
|
||||||
|
|
||||||
|
from typing import Iterable, Optional, Mapping, List
|
||||||
|
|
||||||
|
|
||||||
|
HOST = "edge-chat.messenger.com"
|
||||||
|
|
||||||
|
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",
|
||||||
|
# Data about user-to-user calls
|
||||||
|
# TODO: Investigate the response from this! (A bunch of binary data)
|
||||||
|
# "/t_rtc",
|
||||||
|
# TODO: Find out what this does!
|
||||||
|
# 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",
|
||||||
|
"/webrtc_response",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_cookie_header(session: requests.Session, url: str) -> str:
|
||||||
|
"""Extract a cookie header from a requests session."""
|
||||||
|
# The cookies are extracted this way to make sure they're escaped correctly
|
||||||
|
return requests.cookies.get_cookie_header(
|
||||||
|
session.cookies, requests.Request("GET", url),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_session_id() -> int:
|
||||||
|
"""Generate a random session ID between 1 and 9007199254740991."""
|
||||||
|
return random.randint(1, 2 ** 53)
|
||||||
|
|
||||||
|
|
||||||
|
def mqtt_factory() -> paho.mqtt.client.Client:
|
||||||
|
# Configure internal MQTT handler
|
||||||
|
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)
|
||||||
|
mqtt.tls_set()
|
||||||
|
mqtt.connect_async(HOST, 443, keepalive=10)
|
||||||
|
return mqtt
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_sequence_id(session: _session.Session) -> int:
|
||||||
|
"""Fetch sequence ID."""
|
||||||
|
params = {
|
||||||
|
"limit": 0,
|
||||||
|
"tags": ["INBOX"],
|
||||||
|
"before": None,
|
||||||
|
"includeDeliveryReceipts": False,
|
||||||
|
"includeSeqID": True,
|
||||||
|
}
|
||||||
|
log.debug("Fetching MQTT sequence ID")
|
||||||
|
# Same doc id as in `Client.fetch_threads`
|
||||||
|
(j,) = session._graphql_requests(_graphql.from_doc_id("1349387578499440", params))
|
||||||
|
sequence_id = j["viewer"]["message_threads"]["sync_sequence_id"]
|
||||||
|
if not sequence_id:
|
||||||
|
raise _exception.NotLoggedIn("Failed fetching sequence id")
|
||||||
|
return int(sequence_id)
|
||||||
|
|
||||||
|
|
||||||
|
@attr.s(slots=True, kw_only=kw_only, eq=False)
|
||||||
|
class Listener:
|
||||||
|
"""Listen to incoming Facebook events.
|
||||||
|
|
||||||
|
Initialize a connection to the Facebook MQTT service.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: The session to use when making requests.
|
||||||
|
chat_on: Whether ...
|
||||||
|
foreground: Whether ...
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> listener = fbchat.Listener(session, chat_on=True, foreground=True)
|
||||||
|
"""
|
||||||
|
|
||||||
|
session = attr.ib(type=_session.Session)
|
||||||
|
_chat_on = attr.ib(type=bool)
|
||||||
|
_foreground = attr.ib(type=bool)
|
||||||
|
_mqtt = attr.ib(factory=mqtt_factory, type=paho.mqtt.client.Client)
|
||||||
|
_sync_token = attr.ib(None, type=Optional[str])
|
||||||
|
_sequence_id = attr.ib(None, type=Optional[int])
|
||||||
|
_tmp_events = attr.ib(factory=list, type=List[_events.Event])
|
||||||
|
|
||||||
|
def __attrs_post_init__(self):
|
||||||
|
# Configure callbacks
|
||||||
|
self._mqtt.on_message = self._on_message_handler
|
||||||
|
self._mqtt.on_connect = self._on_connect_handler
|
||||||
|
|
||||||
|
def _handle_ms(self, j):
|
||||||
|
"""Handle /t_ms special logic.
|
||||||
|
|
||||||
|
Returns whether to continue parsing the message.
|
||||||
|
"""
|
||||||
|
# TODO: Merge this with the parsing in _events
|
||||||
|
|
||||||
|
# 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"]
|
||||||
|
return False
|
||||||
|
|
||||||
|
if "errorCode" in j:
|
||||||
|
error = j["errorCode"]
|
||||||
|
# TODO: 'F\xfa\x84\x8c\x85\xf8\xbc-\x88 FB_PAGES_INSUFFICIENT_PERMISSION\x00'
|
||||||
|
if error in ("ERROR_QUEUE_NOT_FOUND", "ERROR_QUEUE_OVERFLOW"):
|
||||||
|
# ERROR_QUEUE_NOT_FOUND means that the queue was deleted, since too
|
||||||
|
# much time passed, or that it was simply missing
|
||||||
|
# ERROR_QUEUE_OVERFLOW means that the sequence id was too small, so
|
||||||
|
# the desired events could not be retrieved
|
||||||
|
log.error(
|
||||||
|
"The MQTT listener was disconnected for too long,"
|
||||||
|
" events may have been lost"
|
||||||
|
)
|
||||||
|
# TODO: Find a way to tell the user that they may now be missing events
|
||||||
|
self._sync_token = None
|
||||||
|
self._sequence_id = None
|
||||||
|
return False
|
||||||
|
log.error("MQTT error code %s received", error)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Update last sequence id
|
||||||
|
# Except for the two cases above, this is always received
|
||||||
|
self._sequence_id = j["lastIssuedSeqId"]
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _on_message_handler(self, client, userdata, message):
|
||||||
|
# Parse payload JSON
|
||||||
|
try:
|
||||||
|
j = _util.parse_json(message.payload.decode("utf-8"))
|
||||||
|
except (_exception.FacebookError, UnicodeDecodeError):
|
||||||
|
log.debug(message.payload)
|
||||||
|
log.exception("Failed parsing MQTT data on %s as JSON", message.topic)
|
||||||
|
return
|
||||||
|
|
||||||
|
log.debug("MQTT payload: %s, %s", message.topic, j)
|
||||||
|
|
||||||
|
if message.topic == "/t_ms":
|
||||||
|
if not self._handle_ms(j):
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
# TODO: Don't handle this in a callback
|
||||||
|
self._tmp_events = list(
|
||||||
|
_events.parse_events(self.session, message.topic, j)
|
||||||
|
)
|
||||||
|
except _exception.ParseError:
|
||||||
|
log.exception("Failed parsing MQTT data")
|
||||||
|
|
||||||
|
def _on_connect_handler(self, client, userdata, flags, rc):
|
||||||
|
if rc == 21:
|
||||||
|
raise _exception.FacebookError(
|
||||||
|
"Failed connecting. Maybe your cookies are wrong?"
|
||||||
|
)
|
||||||
|
if rc != 0:
|
||||||
|
err = paho.mqtt.client.connack_string(rc)
|
||||||
|
log.error("MQTT Connection Error: %s", err)
|
||||||
|
return # Don't try to send publish if the connection failed
|
||||||
|
|
||||||
|
self._messenger_queue_publish()
|
||||||
|
|
||||||
|
def _messenger_queue_publish(self):
|
||||||
|
# configure receiving messages.
|
||||||
|
payload = {
|
||||||
|
"sync_api_version": 10,
|
||||||
|
"max_deltas_able_to_process": 1000,
|
||||||
|
"delta_batch_size": 500,
|
||||||
|
"encoding": "JSON",
|
||||||
|
"entity_fbid": self.session.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()
|
||||||
|
|
||||||
|
username = {
|
||||||
|
# The user ID
|
||||||
|
"u": self.session.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.session._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": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
self._mqtt.username_pw_set(_util.json_minimal(username))
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Cookie": get_cookie_header(
|
||||||
|
self.session._session, "https://edge-chat.messenger.com/chat"
|
||||||
|
),
|
||||||
|
"User-Agent": self.session._session.headers["User-Agent"],
|
||||||
|
"Origin": "https://www.messenger.com",
|
||||||
|
"Host": HOST,
|
||||||
|
}
|
||||||
|
|
||||||
|
# TODO: Is region (lla | atn | odn | others?) important?
|
||||||
|
self._mqtt.ws_set_options(
|
||||||
|
path="/chat?sid={}".format(session_id), headers=headers
|
||||||
|
)
|
||||||
|
|
||||||
|
def _reconnect(self) -> bool:
|
||||||
|
# Try reconnecting
|
||||||
|
self._configure_connect_options()
|
||||||
|
try:
|
||||||
|
self._mqtt.reconnect()
|
||||||
|
return True
|
||||||
|
except (
|
||||||
|
# Taken from .loop_forever
|
||||||
|
paho.mqtt.client.socket.error,
|
||||||
|
OSError,
|
||||||
|
paho.mqtt.client.WebsocketConnectionError,
|
||||||
|
) as e:
|
||||||
|
log.debug("MQTT reconnection failed: %s", e)
|
||||||
|
# Wait before reconnecting
|
||||||
|
self._mqtt._reconnect_wait()
|
||||||
|
return False
|
||||||
|
|
||||||
|
def listen(self) -> Iterable[_events.Event]:
|
||||||
|
"""Run the listening loop continually.
|
||||||
|
|
||||||
|
This is a blocking call, that will yield events as they arrive.
|
||||||
|
|
||||||
|
This will automatically reconnect on errors, except if the errors are one of
|
||||||
|
`PleaseRefresh` or `NotLoggedIn`.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
Print events continually.
|
||||||
|
|
||||||
|
>>> for event in listener.listen():
|
||||||
|
... print(event)
|
||||||
|
"""
|
||||||
|
if self._sequence_id is None:
|
||||||
|
self._sequence_id = fetch_sequence_id(self.session)
|
||||||
|
|
||||||
|
# Make sure we're connected
|
||||||
|
while not self._reconnect():
|
||||||
|
pass
|
||||||
|
|
||||||
|
yield _events.Connect()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
rc = self._mqtt.loop(timeout=1.0)
|
||||||
|
|
||||||
|
# The sequence ID was reset in _handle_ms
|
||||||
|
# TODO: Signal to the user that they should reload their data!
|
||||||
|
if self._sequence_id is None:
|
||||||
|
self._sequence_id = fetch_sequence_id(self.session)
|
||||||
|
self._messenger_queue_publish()
|
||||||
|
|
||||||
|
# If disconnect() has been called
|
||||||
|
# Beware, internal API, may have to change this to something more stable!
|
||||||
|
if self._mqtt._state == paho.mqtt.client.mqtt_cs_disconnecting:
|
||||||
|
break # Stop listening
|
||||||
|
|
||||||
|
if rc != paho.mqtt.client.MQTT_ERR_SUCCESS:
|
||||||
|
# If known/expected error
|
||||||
|
if rc == paho.mqtt.client.MQTT_ERR_CONN_LOST:
|
||||||
|
yield _events.Disconnect(reason="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
|
||||||
|
yield _events.Disconnect(reason="Connection error, retrying")
|
||||||
|
elif rc == paho.mqtt.client.MQTT_ERR_CONN_REFUSED:
|
||||||
|
raise _exception.NotLoggedIn("MQTT connection refused")
|
||||||
|
else:
|
||||||
|
err = paho.mqtt.client.error_string(rc)
|
||||||
|
log.error("MQTT Error: %s", err)
|
||||||
|
reason = "MQTT Error: {}, retrying".format(err)
|
||||||
|
yield _events.Disconnect(reason=reason)
|
||||||
|
|
||||||
|
while not self._reconnect():
|
||||||
|
pass
|
||||||
|
|
||||||
|
yield _events.Connect()
|
||||||
|
|
||||||
|
if self._tmp_events:
|
||||||
|
yield from self._tmp_events
|
||||||
|
self._tmp_events = []
|
||||||
|
|
||||||
|
def disconnect(self) -> None:
|
||||||
|
"""Disconnect the MQTT listener.
|
||||||
|
|
||||||
|
Can be called while listening, which will stop the listening loop.
|
||||||
|
|
||||||
|
The `Listener` object should not be used after this is called!
|
||||||
|
|
||||||
|
Example:
|
||||||
|
Stop the listener when receiving a message with the text "/stop"
|
||||||
|
|
||||||
|
>>> for event in listener.listen():
|
||||||
|
... if isinstance(event, fbchat.MessageEvent):
|
||||||
|
... if event.message.text == "/stop":
|
||||||
|
... listener.disconnect() # Almost the same "break"
|
||||||
|
"""
|
||||||
|
self._mqtt.disconnect()
|
||||||
|
|
||||||
|
def set_foreground(self, value: bool) -> None:
|
||||||
|
"""Set the ``foreground`` value while listening."""
|
||||||
|
# TODO: Document what this actually does!
|
||||||
|
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 within the same thread
|
||||||
|
# info.wait_for_publish()
|
||||||
|
|
||||||
|
def set_chat_on(self, value: bool) -> None:
|
||||||
|
"""Set the ``chat_on`` value while listening."""
|
||||||
|
# TODO: Document what this actually does!
|
||||||
|
# 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 within the same thread
|
||||||
|
# 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)
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
# -*- coding: UTF-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
import attr
|
|
||||||
from ._attachment import Attachment
|
|
||||||
from . import _util
|
|
||||||
|
|
||||||
|
|
||||||
@attr.s(cmp=False)
|
|
||||||
class LocationAttachment(Attachment):
|
|
||||||
"""Represents a user location
|
|
||||||
|
|
||||||
Latitude and longitude OR address is provided by Facebook
|
|
||||||
"""
|
|
||||||
|
|
||||||
#: Latitude of the location
|
|
||||||
latitude = attr.ib(None)
|
|
||||||
#: Longitude of the location
|
|
||||||
longitude = attr.ib(None)
|
|
||||||
#: URL of image showing the map of the location
|
|
||||||
image_url = attr.ib(None, init=False)
|
|
||||||
#: Width of the image
|
|
||||||
image_width = attr.ib(None, init=False)
|
|
||||||
#: Height of the image
|
|
||||||
image_height = attr.ib(None, init=False)
|
|
||||||
#: URL to Bing maps with the location
|
|
||||||
url = attr.ib(None, init=False)
|
|
||||||
# Address of the location
|
|
||||||
address = attr.ib(None)
|
|
||||||
|
|
||||||
# Put here for backwards compatibility, so that the init argument order is preserved
|
|
||||||
uid = attr.ib(None)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _from_graphql(cls, data):
|
|
||||||
url = data.get("url")
|
|
||||||
address = _util.get_url_parameter(_util.get_url_parameter(url, "u"), "where1")
|
|
||||||
try:
|
|
||||||
latitude, longitude = [float(x) for x in address.split(", ")]
|
|
||||||
address = None
|
|
||||||
except ValueError:
|
|
||||||
latitude, longitude = None, None
|
|
||||||
rtn = cls(
|
|
||||||
uid=int(data["deduplication_key"]),
|
|
||||||
latitude=latitude,
|
|
||||||
longitude=longitude,
|
|
||||||
address=address,
|
|
||||||
)
|
|
||||||
media = data.get("media")
|
|
||||||
if media and media.get("image"):
|
|
||||||
image = media["image"]
|
|
||||||
rtn.image_url = image.get("uri")
|
|
||||||
rtn.image_width = image.get("width")
|
|
||||||
rtn.image_height = image.get("height")
|
|
||||||
rtn.url = url
|
|
||||||
return rtn
|
|
||||||
|
|
||||||
|
|
||||||
@attr.s(cmp=False, init=False)
|
|
||||||
class LiveLocationAttachment(LocationAttachment):
|
|
||||||
"""Represents a live user location"""
|
|
||||||
|
|
||||||
#: Name of the location
|
|
||||||
name = attr.ib(None)
|
|
||||||
#: Timestamp when live location expires
|
|
||||||
expiration_time = attr.ib(None)
|
|
||||||
#: True if live location is expired
|
|
||||||
is_expired = attr.ib(None)
|
|
||||||
|
|
||||||
def __init__(self, name=None, expiration_time=None, is_expired=None, **kwargs):
|
|
||||||
super(LiveLocationAttachment, self).__init__(**kwargs)
|
|
||||||
self.expiration_time = expiration_time
|
|
||||||
self.is_expired = is_expired
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _from_pull(cls, data):
|
|
||||||
return cls(
|
|
||||||
uid=data["id"],
|
|
||||||
latitude=data["coordinate"]["latitude"] / (10 ** 8)
|
|
||||||
if not data.get("stopReason")
|
|
||||||
else None,
|
|
||||||
longitude=data["coordinate"]["longitude"] / (10 ** 8)
|
|
||||||
if not data.get("stopReason")
|
|
||||||
else None,
|
|
||||||
name=data.get("locationTitle"),
|
|
||||||
expiration_time=data["expirationTime"],
|
|
||||||
is_expired=bool(data.get("stopReason")),
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _from_graphql(cls, data):
|
|
||||||
target = data["target"]
|
|
||||||
rtn = cls(
|
|
||||||
uid=int(target["live_location_id"]),
|
|
||||||
latitude=target["coordinate"]["latitude"]
|
|
||||||
if target.get("coordinate")
|
|
||||||
else None,
|
|
||||||
longitude=target["coordinate"]["longitude"]
|
|
||||||
if target.get("coordinate")
|
|
||||||
else None,
|
|
||||||
name=data["title_with_entities"]["text"],
|
|
||||||
expiration_time=target.get("expiration_time"),
|
|
||||||
is_expired=target.get("is_expired"),
|
|
||||||
)
|
|
||||||
media = data.get("media")
|
|
||||||
if media and media.get("image"):
|
|
||||||
image = media["image"]
|
|
||||||
rtn.image_url = image.get("uri")
|
|
||||||
rtn.image_width = image.get("width")
|
|
||||||
rtn.image_height = image.get("height")
|
|
||||||
rtn.url = data.get("url")
|
|
||||||
return rtn
|
|
||||||
@@ -1,345 +0,0 @@
|
|||||||
# -*- coding: UTF-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
import attr
|
|
||||||
import json
|
|
||||||
from string import Formatter
|
|
||||||
from . import _util, _attachment, _location, _file, _quick_reply, _sticker
|
|
||||||
from ._core import Enum
|
|
||||||
|
|
||||||
|
|
||||||
class EmojiSize(Enum):
|
|
||||||
"""Used to specify the size of a sent emoji"""
|
|
||||||
|
|
||||||
LARGE = "369239383222810"
|
|
||||||
MEDIUM = "369239343222814"
|
|
||||||
SMALL = "369239263222822"
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _from_tags(cls, tags):
|
|
||||||
string_to_emojisize = {
|
|
||||||
"large": cls.LARGE,
|
|
||||||
"medium": cls.MEDIUM,
|
|
||||||
"small": cls.SMALL,
|
|
||||||
"l": cls.LARGE,
|
|
||||||
"m": cls.MEDIUM,
|
|
||||||
"s": cls.SMALL,
|
|
||||||
}
|
|
||||||
for tag in tags or ():
|
|
||||||
data = tag.split(":", maxsplit=1)
|
|
||||||
if len(data) > 1 and data[0] == "hot_emoji_size":
|
|
||||||
return string_to_emojisize.get(data[1])
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class MessageReaction(Enum):
|
|
||||||
"""Used to specify a message reaction"""
|
|
||||||
|
|
||||||
LOVE = "😍"
|
|
||||||
SMILE = "😆"
|
|
||||||
WOW = "😮"
|
|
||||||
SAD = "😢"
|
|
||||||
ANGRY = "😠"
|
|
||||||
YES = "👍"
|
|
||||||
NO = "👎"
|
|
||||||
|
|
||||||
|
|
||||||
@attr.s(cmp=False)
|
|
||||||
class Mention(object):
|
|
||||||
"""Represents a @mention"""
|
|
||||||
|
|
||||||
#: The thread ID the mention is pointing at
|
|
||||||
thread_id = attr.ib()
|
|
||||||
#: The character where the mention starts
|
|
||||||
offset = attr.ib(0)
|
|
||||||
#: The length of the mention
|
|
||||||
length = attr.ib(10)
|
|
||||||
|
|
||||||
|
|
||||||
@attr.s(cmp=False)
|
|
||||||
class Message(object):
|
|
||||||
"""Represents a Facebook message"""
|
|
||||||
|
|
||||||
#: The actual message
|
|
||||||
text = attr.ib(None)
|
|
||||||
#: A list of :class:`Mention` objects
|
|
||||||
mentions = attr.ib(factory=list, converter=lambda x: [] if x is None else x)
|
|
||||||
#: A :class:`EmojiSize`. Size of a sent emoji
|
|
||||||
emoji_size = attr.ib(None)
|
|
||||||
#: The message ID
|
|
||||||
uid = attr.ib(None, init=False)
|
|
||||||
#: ID of the sender
|
|
||||||
author = attr.ib(None, init=False)
|
|
||||||
#: Timestamp of when the message was sent
|
|
||||||
timestamp = attr.ib(None, init=False)
|
|
||||||
#: Whether the message is read
|
|
||||||
is_read = attr.ib(None, init=False)
|
|
||||||
#: A list of pepole IDs who read the message, works only with :func:`fbchat.Client.fetchThreadMessages`
|
|
||||||
read_by = attr.ib(factory=list, init=False)
|
|
||||||
#: A dict with user's IDs as keys, and their :class:`MessageReaction` as values
|
|
||||||
reactions = attr.ib(factory=dict, init=False)
|
|
||||||
#: A :class:`Sticker`
|
|
||||||
sticker = attr.ib(None)
|
|
||||||
#: A list of attachments
|
|
||||||
attachments = attr.ib(factory=list, converter=lambda x: [] if x is None else x)
|
|
||||||
#: A list of :class:`QuickReply`
|
|
||||||
quick_replies = attr.ib(factory=list, converter=lambda x: [] if x is None else x)
|
|
||||||
#: Whether the message is unsent (deleted for everyone)
|
|
||||||
unsent = attr.ib(False, init=False)
|
|
||||||
#: Message ID you want to reply to
|
|
||||||
reply_to_id = attr.ib(None)
|
|
||||||
#: Replied message
|
|
||||||
replied_to = attr.ib(None, init=False)
|
|
||||||
#: Whether the message was forwarded
|
|
||||||
forwarded = attr.ib(False, init=False)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def formatMentions(cls, text, *args, **kwargs):
|
|
||||||
"""Like `str.format`, but takes tuples with a thread id and text instead.
|
|
||||||
|
|
||||||
Returns a `Message` object, with the formatted string and relevant mentions.
|
|
||||||
|
|
||||||
>>> 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.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=[]>
|
|
||||||
"""
|
|
||||||
result = ""
|
|
||||||
mentions = list()
|
|
||||||
offset = 0
|
|
||||||
f = Formatter()
|
|
||||||
field_names = [field_name[1] for field_name in f.parse(text)]
|
|
||||||
automatic = "" in field_names
|
|
||||||
i = 0
|
|
||||||
|
|
||||||
for (literal_text, field_name, format_spec, conversion) in f.parse(text):
|
|
||||||
offset += len(literal_text)
|
|
||||||
result += literal_text
|
|
||||||
|
|
||||||
if field_name is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if field_name == "":
|
|
||||||
field_name = str(i)
|
|
||||||
i += 1
|
|
||||||
elif automatic and field_name.isdigit():
|
|
||||||
raise ValueError(
|
|
||||||
"cannot switch from automatic field numbering to manual field specification"
|
|
||||||
)
|
|
||||||
|
|
||||||
thread_id, name = f.get_field(field_name, args, kwargs)[0]
|
|
||||||
|
|
||||||
if format_spec:
|
|
||||||
name = f.format_field(name, format_spec)
|
|
||||||
if conversion:
|
|
||||||
name = f.convert_field(name, conversion)
|
|
||||||
|
|
||||||
result += name
|
|
||||||
mentions.append(
|
|
||||||
Mention(thread_id=thread_id, offset=offset, length=len(name))
|
|
||||||
)
|
|
||||||
offset += len(name)
|
|
||||||
|
|
||||||
message = cls(text=result, mentions=mentions)
|
|
||||||
return message
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _get_forwarded_from_tags(tags):
|
|
||||||
if tags is None:
|
|
||||||
return False
|
|
||||||
return any(map(lambda tag: "forward" in tag or "copy" in tag, tags))
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _from_graphql(cls, data):
|
|
||||||
if data.get("message_sender") is None:
|
|
||||||
data["message_sender"] = {}
|
|
||||||
if data.get("message") is None:
|
|
||||||
data["message"] = {}
|
|
||||||
tags = data.get("tags_list")
|
|
||||||
rtn = cls(
|
|
||||||
text=data["message"].get("text"),
|
|
||||||
mentions=[
|
|
||||||
Mention(
|
|
||||||
m.get("entity", {}).get("id"),
|
|
||||||
offset=m.get("offset"),
|
|
||||||
length=m.get("length"),
|
|
||||||
)
|
|
||||||
for m in data["message"].get("ranges") or ()
|
|
||||||
],
|
|
||||||
emoji_size=EmojiSize._from_tags(tags),
|
|
||||||
sticker=_sticker.Sticker._from_graphql(data.get("sticker")),
|
|
||||||
)
|
|
||||||
rtn.forwarded = cls._get_forwarded_from_tags(tags)
|
|
||||||
rtn.uid = str(data["message_id"])
|
|
||||||
rtn.author = str(data["message_sender"]["id"])
|
|
||||||
rtn.timestamp = data.get("timestamp_precise")
|
|
||||||
rtn.unsent = False
|
|
||||||
if data.get("unread") is not None:
|
|
||||||
rtn.is_read = not data["unread"]
|
|
||||||
rtn.reactions = {
|
|
||||||
str(r["user"]["id"]): MessageReaction._extend_if_invalid(r["reaction"])
|
|
||||||
for r in data["message_reactions"]
|
|
||||||
}
|
|
||||||
if data.get("blob_attachments") is not None:
|
|
||||||
rtn.attachments = [
|
|
||||||
_file.graphql_to_attachment(attachment)
|
|
||||||
for attachment in data["blob_attachments"]
|
|
||||||
]
|
|
||||||
if data.get("platform_xmd_encoded"):
|
|
||||||
quick_replies = json.loads(data["platform_xmd_encoded"]).get(
|
|
||||||
"quick_replies"
|
|
||||||
)
|
|
||||||
if isinstance(quick_replies, list):
|
|
||||||
rtn.quick_replies = [
|
|
||||||
_quick_reply.graphql_to_quick_reply(q) for q in quick_replies
|
|
||||||
]
|
|
||||||
elif isinstance(quick_replies, dict):
|
|
||||||
rtn.quick_replies = [
|
|
||||||
_quick_reply.graphql_to_quick_reply(quick_replies, is_response=True)
|
|
||||||
]
|
|
||||||
if data.get("extensible_attachment") is not None:
|
|
||||||
attachment = graphql_to_extensible_attachment(data["extensible_attachment"])
|
|
||||||
if isinstance(attachment, _attachment.UnsentMessage):
|
|
||||||
rtn.unsent = True
|
|
||||||
elif attachment:
|
|
||||||
rtn.attachments.append(attachment)
|
|
||||||
if data.get("replied_to_message") is not None:
|
|
||||||
rtn.replied_to = cls._from_graphql(data["replied_to_message"]["message"])
|
|
||||||
rtn.reply_to_id = rtn.replied_to.uid
|
|
||||||
return rtn
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _from_reply(cls, data):
|
|
||||||
tags = data["messageMetadata"].get("tags")
|
|
||||||
rtn = cls(
|
|
||||||
text=data.get("body"),
|
|
||||||
mentions=[
|
|
||||||
Mention(m.get("i"), offset=m.get("o"), length=m.get("l"))
|
|
||||||
for m in json.loads(data.get("data", {}).get("prng", "[]"))
|
|
||||||
],
|
|
||||||
emoji_size=EmojiSize._from_tags(tags),
|
|
||||||
)
|
|
||||||
metadata = data.get("messageMetadata", {})
|
|
||||||
rtn.forwarded = cls._get_forwarded_from_tags(tags)
|
|
||||||
rtn.uid = metadata.get("messageId")
|
|
||||||
rtn.author = str(metadata.get("actorFbId"))
|
|
||||||
rtn.timestamp = metadata.get("timestamp")
|
|
||||||
rtn.unsent = False
|
|
||||||
if data.get("data", {}).get("platform_xmd"):
|
|
||||||
quick_replies = json.loads(data["data"]["platform_xmd"]).get(
|
|
||||||
"quick_replies"
|
|
||||||
)
|
|
||||||
if isinstance(quick_replies, list):
|
|
||||||
rtn.quick_replies = [
|
|
||||||
_quick_reply.graphql_to_quick_reply(q) for q in quick_replies
|
|
||||||
]
|
|
||||||
elif isinstance(quick_replies, dict):
|
|
||||||
rtn.quick_replies = [
|
|
||||||
_quick_reply.graphql_to_quick_reply(quick_replies, is_response=True)
|
|
||||||
]
|
|
||||||
if data.get("attachments") is not None:
|
|
||||||
for attachment in data["attachments"]:
|
|
||||||
attachment = json.loads(attachment["mercuryJSON"])
|
|
||||||
if attachment.get("blob_attachment"):
|
|
||||||
rtn.attachments.append(
|
|
||||||
_file.graphql_to_attachment(attachment["blob_attachment"])
|
|
||||||
)
|
|
||||||
if attachment.get("extensible_attachment"):
|
|
||||||
extensible_attachment = graphql_to_extensible_attachment(
|
|
||||||
attachment["extensible_attachment"]
|
|
||||||
)
|
|
||||||
if isinstance(extensible_attachment, _attachment.UnsentMessage):
|
|
||||||
rtn.unsent = True
|
|
||||||
else:
|
|
||||||
rtn.attachments.append(extensible_attachment)
|
|
||||||
if attachment.get("sticker_attachment"):
|
|
||||||
rtn.sticker = _sticker.Sticker._from_graphql(
|
|
||||||
attachment["sticker_attachment"]
|
|
||||||
)
|
|
||||||
return rtn
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _from_pull(cls, data, mid=None, tags=None, author=None, timestamp=None):
|
|
||||||
rtn = cls(text=data.get("body"))
|
|
||||||
rtn.uid = mid
|
|
||||||
rtn.author = author
|
|
||||||
rtn.timestamp = timestamp
|
|
||||||
|
|
||||||
if data.get("data") and data["data"].get("prng"):
|
|
||||||
try:
|
|
||||||
rtn.mentions = [
|
|
||||||
Mention(
|
|
||||||
str(mention.get("i")),
|
|
||||||
offset=mention.get("o"),
|
|
||||||
length=mention.get("l"),
|
|
||||||
)
|
|
||||||
for mention in _util.parse_json(data["data"]["prng"])
|
|
||||||
]
|
|
||||||
except Exception:
|
|
||||||
_util.log.exception("An exception occured while reading attachments")
|
|
||||||
|
|
||||||
if data.get("attachments"):
|
|
||||||
try:
|
|
||||||
for a in data["attachments"]:
|
|
||||||
mercury = a["mercury"]
|
|
||||||
if mercury.get("blob_attachment"):
|
|
||||||
image_metadata = a.get("imageMetadata", {})
|
|
||||||
attach_type = mercury["blob_attachment"]["__typename"]
|
|
||||||
attachment = _file.graphql_to_attachment(
|
|
||||||
mercury["blob_attachment"]
|
|
||||||
)
|
|
||||||
|
|
||||||
if attach_type in [
|
|
||||||
"MessageFile",
|
|
||||||
"MessageVideo",
|
|
||||||
"MessageAudio",
|
|
||||||
]:
|
|
||||||
# TODO: Add more data here for audio files
|
|
||||||
attachment.size = int(a["fileSize"])
|
|
||||||
rtn.attachments.append(attachment)
|
|
||||||
|
|
||||||
elif mercury.get("sticker_attachment"):
|
|
||||||
rtn.sticker = _sticker.Sticker._from_graphql(
|
|
||||||
mercury["sticker_attachment"]
|
|
||||||
)
|
|
||||||
|
|
||||||
elif mercury.get("extensible_attachment"):
|
|
||||||
attachment = graphql_to_extensible_attachment(
|
|
||||||
mercury["extensible_attachment"]
|
|
||||||
)
|
|
||||||
if isinstance(attachment, _attachment.UnsentMessage):
|
|
||||||
rtn.unsent = True
|
|
||||||
elif attachment:
|
|
||||||
rtn.attachments.append(attachment)
|
|
||||||
|
|
||||||
except Exception:
|
|
||||||
_util.log.exception(
|
|
||||||
"An exception occured while reading attachments: {}".format(
|
|
||||||
data["attachments"]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
rtn.emoji_size = EmojiSize._from_tags(tags)
|
|
||||||
rtn.forwarded = cls._get_forwarded_from_tags(tags)
|
|
||||||
return rtn
|
|
||||||
|
|
||||||
|
|
||||||
def graphql_to_extensible_attachment(data):
|
|
||||||
story = data.get("story_attachment")
|
|
||||||
if not story:
|
|
||||||
return None
|
|
||||||
|
|
||||||
target = story.get("target")
|
|
||||||
if not target:
|
|
||||||
return _attachment.UnsentMessage(uid=data.get("legacy_attachment_id"))
|
|
||||||
|
|
||||||
_type = target["__typename"]
|
|
||||||
if _type == "MessageLocation":
|
|
||||||
return _location.LocationAttachment._from_graphql(story)
|
|
||||||
elif _type == "MessageLiveLocation":
|
|
||||||
return _location.LiveLocationAttachment._from_graphql(story)
|
|
||||||
elif _type in ["ExternalUrl", "Story"]:
|
|
||||||
return _attachment.ShareAttachment._from_graphql(story)
|
|
||||||
|
|
||||||
return None
|
|
||||||
9
fbchat/_models/__init__.py
Normal file
9
fbchat/_models/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
from ._common import *
|
||||||
|
from ._attachment import *
|
||||||
|
from ._file import *
|
||||||
|
from ._location import *
|
||||||
|
from ._plan import *
|
||||||
|
from ._poll import *
|
||||||
|
from ._quick_reply import *
|
||||||
|
from ._sticker import *
|
||||||
|
from ._message import *
|
||||||
81
fbchat/_models/_attachment.py
Normal file
81
fbchat/_models/_attachment.py
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import attr
|
||||||
|
from . import Image
|
||||||
|
from .._common import attrs_default
|
||||||
|
from .. import _util
|
||||||
|
|
||||||
|
from typing import Optional, Sequence
|
||||||
|
|
||||||
|
|
||||||
|
@attrs_default
|
||||||
|
class Attachment:
|
||||||
|
"""Represents a Facebook attachment."""
|
||||||
|
|
||||||
|
#: The attachment ID
|
||||||
|
id = attr.ib(None, type=Optional[str])
|
||||||
|
|
||||||
|
|
||||||
|
@attrs_default
|
||||||
|
class UnsentMessage(Attachment):
|
||||||
|
"""Represents an unsent message attachment."""
|
||||||
|
|
||||||
|
|
||||||
|
@attrs_default
|
||||||
|
class ShareAttachment(Attachment):
|
||||||
|
"""Represents a shared item (e.g. URL) attachment."""
|
||||||
|
|
||||||
|
#: ID of the author of the shared post
|
||||||
|
author = attr.ib(None, type=Optional[str])
|
||||||
|
#: Target URL
|
||||||
|
url = attr.ib(None, type=Optional[str])
|
||||||
|
#: Original URL if Facebook redirects the URL
|
||||||
|
original_url = attr.ib(None, type=Optional[str])
|
||||||
|
#: Title of the attachment
|
||||||
|
title = attr.ib(None, type=Optional[str])
|
||||||
|
#: Description of the attachment
|
||||||
|
description = attr.ib(None, type=Optional[str])
|
||||||
|
#: Name of the source
|
||||||
|
source = attr.ib(None, type=Optional[str])
|
||||||
|
#: The attached image
|
||||||
|
image = attr.ib(None, type=Optional[Image])
|
||||||
|
#: URL of the original image if Facebook uses ``safe_image``
|
||||||
|
original_image_url = attr.ib(None, type=Optional[str])
|
||||||
|
#: List of additional attachments
|
||||||
|
attachments = attr.ib(factory=list, type=Sequence[Attachment])
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _from_graphql(cls, data):
|
||||||
|
from . import _file
|
||||||
|
|
||||||
|
image = None
|
||||||
|
original_image_url = None
|
||||||
|
media = data.get("media")
|
||||||
|
if media and media.get("image"):
|
||||||
|
image = Image._from_uri(media["image"])
|
||||||
|
original_image_url = (
|
||||||
|
_util.get_url_parameter(image.url, "url")
|
||||||
|
if "/safe_image.php" in image.url
|
||||||
|
else image.url
|
||||||
|
)
|
||||||
|
|
||||||
|
url = data.get("url")
|
||||||
|
return cls(
|
||||||
|
id=data.get("deduplication_key"),
|
||||||
|
author=data["target"]["actors"][0]["id"]
|
||||||
|
if data["target"].get("actors")
|
||||||
|
else None,
|
||||||
|
url=url,
|
||||||
|
original_url=_util.get_url_parameter(url, "u")
|
||||||
|
if "/l.php?u=" in url
|
||||||
|
else url,
|
||||||
|
title=data["title_with_entities"].get("text"),
|
||||||
|
description=data["description"].get("text")
|
||||||
|
if data.get("description")
|
||||||
|
else None,
|
||||||
|
source=data["source"].get("text") if data.get("source") else None,
|
||||||
|
image=image,
|
||||||
|
original_image_url=original_image_url,
|
||||||
|
attachments=[
|
||||||
|
_file.graphql_to_subattachment(attachment)
|
||||||
|
for attachment in data.get("subattachments")
|
||||||
|
],
|
||||||
|
)
|
||||||
81
fbchat/_models/_common.py
Normal file
81
fbchat/_models/_common.py
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import attr
|
||||||
|
import datetime
|
||||||
|
import enum
|
||||||
|
from .._common import attrs_default
|
||||||
|
from .. import _util
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class ThreadLocation(enum.Enum):
|
||||||
|
"""Used to specify where a thread is located (inbox, pending, archived, other)."""
|
||||||
|
|
||||||
|
INBOX = "INBOX"
|
||||||
|
PENDING = "PENDING"
|
||||||
|
ARCHIVED = "ARCHIVED"
|
||||||
|
OTHER = "OTHER"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _parse(cls, value: str):
|
||||||
|
return cls(value.lstrip("FOLDER_"))
|
||||||
|
|
||||||
|
|
||||||
|
@attrs_default
|
||||||
|
class ActiveStatus:
|
||||||
|
#: Whether the user is active now
|
||||||
|
active = attr.ib(type=bool)
|
||||||
|
#: When the user was last active
|
||||||
|
last_active = attr.ib(None, type=Optional[datetime.datetime])
|
||||||
|
#: Whether the user is playing Messenger game now
|
||||||
|
in_game = attr.ib(None, type=Optional[bool])
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _from_orca_presence(cls, data):
|
||||||
|
# TODO: Handle `c` and `vc` keys (Probably some binary data)
|
||||||
|
return cls(
|
||||||
|
active=data["p"] in [2, 3],
|
||||||
|
last_active=_util.seconds_to_datetime(data["l"]) if "l" in data else None,
|
||||||
|
in_game=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@attrs_default
|
||||||
|
class Image:
|
||||||
|
#: URL to the image
|
||||||
|
url = attr.ib(type=str)
|
||||||
|
#: Width of the image
|
||||||
|
width = attr.ib(None, type=Optional[int])
|
||||||
|
#: Height of the image
|
||||||
|
height = attr.ib(None, type=Optional[int])
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _from_uri(cls, data):
|
||||||
|
return cls(
|
||||||
|
url=data["uri"],
|
||||||
|
width=int(data["width"]) if data.get("width") else None,
|
||||||
|
height=int(data["height"]) if data.get("height") else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _from_url(cls, data):
|
||||||
|
return cls(
|
||||||
|
url=data["url"],
|
||||||
|
width=int(data["width"]) if data.get("width") else None,
|
||||||
|
height=int(data["height"]) if data.get("height") else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _from_uri_or_none(cls, data):
|
||||||
|
if data is None:
|
||||||
|
return None
|
||||||
|
if data.get("uri") is None:
|
||||||
|
return None
|
||||||
|
return cls._from_uri(data)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _from_url_or_none(cls, data):
|
||||||
|
if data is None:
|
||||||
|
return None
|
||||||
|
if data.get("url") is None:
|
||||||
|
return None
|
||||||
|
return cls._from_url(data)
|
||||||
195
fbchat/_models/_file.py
Normal file
195
fbchat/_models/_file.py
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import attr
|
||||||
|
import datetime
|
||||||
|
from . import Image, Attachment
|
||||||
|
from .._common import attrs_default
|
||||||
|
from .. import _util
|
||||||
|
|
||||||
|
from typing import Set, Optional
|
||||||
|
|
||||||
|
|
||||||
|
@attrs_default
|
||||||
|
class FileAttachment(Attachment):
|
||||||
|
"""Represents a file that has been sent as a Facebook attachment."""
|
||||||
|
|
||||||
|
#: URL where you can download the file
|
||||||
|
url = attr.ib(None, type=Optional[str])
|
||||||
|
#: Size of the file in bytes
|
||||||
|
size = attr.ib(None, type=Optional[int])
|
||||||
|
#: Name of the file
|
||||||
|
name = attr.ib(None, type=Optional[str])
|
||||||
|
#: Whether Facebook determines that this file may be harmful
|
||||||
|
is_malicious = attr.ib(None, type=Optional[bool])
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _from_graphql(cls, data, size=None):
|
||||||
|
return cls(
|
||||||
|
url=data.get("url"),
|
||||||
|
size=size,
|
||||||
|
name=data.get("filename"),
|
||||||
|
is_malicious=data.get("is_malicious"),
|
||||||
|
id=data.get("message_file_fbid"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@attrs_default
|
||||||
|
class AudioAttachment(Attachment):
|
||||||
|
"""Represents an audio file that has been sent as a Facebook attachment."""
|
||||||
|
|
||||||
|
#: Name of the file
|
||||||
|
filename = attr.ib(None, type=Optional[str])
|
||||||
|
#: URL of the audio file
|
||||||
|
url = attr.ib(None, type=Optional[str])
|
||||||
|
#: Duration of the audio clip
|
||||||
|
duration = attr.ib(None, type=Optional[datetime.timedelta])
|
||||||
|
#: Audio type
|
||||||
|
audio_type = attr.ib(None, type=Optional[str])
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _from_graphql(cls, data):
|
||||||
|
return cls(
|
||||||
|
filename=data.get("filename"),
|
||||||
|
url=data.get("playable_url"),
|
||||||
|
duration=_util.millis_to_timedelta(data.get("playable_duration_in_ms")),
|
||||||
|
audio_type=data.get("audio_type"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@attrs_default
|
||||||
|
class ImageAttachment(Attachment):
|
||||||
|
"""Represents an image that has been sent as a Facebook attachment.
|
||||||
|
|
||||||
|
To retrieve the full image URL, use: `Client.fetch_image_url`, and pass it the id of
|
||||||
|
the image attachment.
|
||||||
|
"""
|
||||||
|
|
||||||
|
#: The extension of the original image (e.g. ``png``)
|
||||||
|
original_extension = attr.ib(None, type=Optional[str])
|
||||||
|
#: Width of original image
|
||||||
|
width = attr.ib(None, converter=_util.int_or_none, type=Optional[int])
|
||||||
|
#: Height of original image
|
||||||
|
height = attr.ib(None, converter=_util.int_or_none, type=Optional[int])
|
||||||
|
#: Whether the image is animated
|
||||||
|
is_animated = attr.ib(None, type=Optional[bool])
|
||||||
|
#: A set, containing variously sized / various types of previews of the image
|
||||||
|
previews = attr.ib(factory=set, type=Set[Image])
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _from_graphql(cls, data):
|
||||||
|
previews = {
|
||||||
|
Image._from_uri_or_none(data.get("thumbnail")),
|
||||||
|
Image._from_uri_or_none(data.get("preview") or data.get("preview_image")),
|
||||||
|
Image._from_uri_or_none(data.get("large_preview")),
|
||||||
|
Image._from_uri_or_none(data.get("animated_image")),
|
||||||
|
}
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
original_extension=data.get("original_extension")
|
||||||
|
or (data["filename"].split("-")[0] if data.get("filename") else None),
|
||||||
|
width=data.get("original_dimensions", {}).get("width"),
|
||||||
|
height=data.get("original_dimensions", {}).get("height"),
|
||||||
|
is_animated=data["__typename"] == "MessageAnimatedImage",
|
||||||
|
previews={p for p in previews if p},
|
||||||
|
id=data.get("legacy_attachment_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _from_list(cls, data):
|
||||||
|
previews = {
|
||||||
|
Image._from_uri_or_none(data["image"]),
|
||||||
|
Image._from_uri(data["image1"]),
|
||||||
|
Image._from_uri(data["image2"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
width=data["original_dimensions"].get("x"),
|
||||||
|
height=data["original_dimensions"].get("y"),
|
||||||
|
previews={p for p in previews if p},
|
||||||
|
id=data["legacy_attachment_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@attrs_default
|
||||||
|
class VideoAttachment(Attachment):
|
||||||
|
"""Represents a video that has been sent as a Facebook attachment."""
|
||||||
|
|
||||||
|
#: Size of the original video in bytes
|
||||||
|
size = attr.ib(None, type=Optional[int])
|
||||||
|
#: Width of original video
|
||||||
|
width = attr.ib(None, type=Optional[int])
|
||||||
|
#: Height of original video
|
||||||
|
height = attr.ib(None, type=Optional[int])
|
||||||
|
#: Length of video
|
||||||
|
duration = attr.ib(None, type=Optional[datetime.timedelta])
|
||||||
|
#: URL to very compressed preview video
|
||||||
|
preview_url = attr.ib(None, type=Optional[str])
|
||||||
|
#: A set, containing variously sized previews of the video
|
||||||
|
previews = attr.ib(factory=set, type=Set[Image])
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _from_graphql(cls, data, size=None):
|
||||||
|
previews = {
|
||||||
|
Image._from_uri_or_none(data.get("chat_image")),
|
||||||
|
Image._from_uri_or_none(data.get("inbox_image")),
|
||||||
|
Image._from_uri_or_none(data.get("large_image")),
|
||||||
|
}
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
size=size,
|
||||||
|
width=data.get("original_dimensions", {}).get("width"),
|
||||||
|
height=data.get("original_dimensions", {}).get("height"),
|
||||||
|
duration=_util.millis_to_timedelta(data.get("playable_duration_in_ms")),
|
||||||
|
preview_url=data.get("playable_url"),
|
||||||
|
previews={p for p in previews if p},
|
||||||
|
id=data.get("legacy_attachment_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _from_subattachment(cls, data):
|
||||||
|
media = data["media"]
|
||||||
|
image = Image._from_uri_or_none(media.get("image"))
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
duration=_util.millis_to_timedelta(media.get("playable_duration_in_ms")),
|
||||||
|
preview_url=media.get("playable_url"),
|
||||||
|
previews={image} if image else {},
|
||||||
|
id=data["target"].get("video_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _from_list(cls, data):
|
||||||
|
previews = {
|
||||||
|
Image._from_uri(data["image"]),
|
||||||
|
Image._from_uri(data["image1"]),
|
||||||
|
Image._from_uri(data["image2"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
width=data["original_dimensions"].get("x"),
|
||||||
|
height=data["original_dimensions"].get("y"),
|
||||||
|
previews=previews,
|
||||||
|
id=data["legacy_attachment_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def graphql_to_attachment(data, size=None):
|
||||||
|
_type = data["__typename"]
|
||||||
|
if _type in ["MessageImage", "MessageAnimatedImage"]:
|
||||||
|
return ImageAttachment._from_graphql(data)
|
||||||
|
elif _type == "MessageVideo":
|
||||||
|
return VideoAttachment._from_graphql(data, size=size)
|
||||||
|
elif _type == "MessageAudio":
|
||||||
|
return AudioAttachment._from_graphql(data)
|
||||||
|
elif _type == "MessageFile":
|
||||||
|
return FileAttachment._from_graphql(data, size=size)
|
||||||
|
|
||||||
|
return Attachment(id=data.get("legacy_attachment_id"))
|
||||||
|
|
||||||
|
|
||||||
|
def graphql_to_subattachment(data):
|
||||||
|
target = data.get("target")
|
||||||
|
type_ = target.get("__typename") if target else None
|
||||||
|
|
||||||
|
if type_ == "Video":
|
||||||
|
return VideoAttachment._from_subattachment(data)
|
||||||
|
|
||||||
|
return None
|
||||||
100
fbchat/_models/_location.py
Normal file
100
fbchat/_models/_location.py
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import attr
|
||||||
|
import datetime
|
||||||
|
from . import Image, Attachment
|
||||||
|
from .._common import attrs_default
|
||||||
|
from .. import _util, _exception
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
@attrs_default
|
||||||
|
class LocationAttachment(Attachment):
|
||||||
|
"""Represents a user location.
|
||||||
|
|
||||||
|
Latitude and longitude OR address is provided by Facebook.
|
||||||
|
"""
|
||||||
|
|
||||||
|
#: Latitude of the location
|
||||||
|
latitude = attr.ib(None, type=Optional[float])
|
||||||
|
#: Longitude of the location
|
||||||
|
longitude = attr.ib(None, type=Optional[float])
|
||||||
|
#: Image showing the map of the location
|
||||||
|
image = attr.ib(None, type=Optional[Image])
|
||||||
|
#: URL to Bing maps with the location
|
||||||
|
url = attr.ib(None, type=Optional[str])
|
||||||
|
# Address of the location
|
||||||
|
address = attr.ib(None, type=Optional[str])
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _from_graphql(cls, data):
|
||||||
|
url = data.get("url")
|
||||||
|
address = _util.get_url_parameter(_util.get_url_parameter(url, "u"), "where1")
|
||||||
|
if not address:
|
||||||
|
raise _exception.ParseError("Could not find location address", data=data)
|
||||||
|
try:
|
||||||
|
latitude, longitude = [float(x) for x in address.split(", ")]
|
||||||
|
address = None
|
||||||
|
except ValueError:
|
||||||
|
latitude, longitude = None, None
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
id=int(data["deduplication_key"]),
|
||||||
|
latitude=latitude,
|
||||||
|
longitude=longitude,
|
||||||
|
image=Image._from_uri_or_none(data["media"].get("image"))
|
||||||
|
if data.get("media")
|
||||||
|
else None,
|
||||||
|
url=url,
|
||||||
|
address=address,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@attrs_default
|
||||||
|
class LiveLocationAttachment(LocationAttachment):
|
||||||
|
"""Represents a live user location."""
|
||||||
|
|
||||||
|
#: Name of the location
|
||||||
|
name = attr.ib(None, type=Optional[str])
|
||||||
|
#: When live location expires
|
||||||
|
expires_at = attr.ib(None, type=Optional[datetime.datetime])
|
||||||
|
#: True if live location is expired
|
||||||
|
is_expired = attr.ib(None, type=Optional[bool])
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _from_pull(cls, data):
|
||||||
|
return cls(
|
||||||
|
id=data["id"],
|
||||||
|
latitude=data["coordinate"]["latitude"] / (10 ** 8)
|
||||||
|
if not data.get("stopReason")
|
||||||
|
else None,
|
||||||
|
longitude=data["coordinate"]["longitude"] / (10 ** 8)
|
||||||
|
if not data.get("stopReason")
|
||||||
|
else None,
|
||||||
|
name=data.get("locationTitle"),
|
||||||
|
expires_at=_util.millis_to_datetime(data["expirationTime"]),
|
||||||
|
is_expired=bool(data.get("stopReason")),
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _from_graphql(cls, data):
|
||||||
|
target = data["target"]
|
||||||
|
|
||||||
|
image = None
|
||||||
|
media = data.get("media")
|
||||||
|
if media and media.get("image"):
|
||||||
|
image = Image._from_uri(media["image"])
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
id=int(target["live_location_id"]),
|
||||||
|
latitude=target["coordinate"]["latitude"]
|
||||||
|
if target.get("coordinate")
|
||||||
|
else None,
|
||||||
|
longitude=target["coordinate"]["longitude"]
|
||||||
|
if target.get("coordinate")
|
||||||
|
else None,
|
||||||
|
image=image,
|
||||||
|
url=data.get("url"),
|
||||||
|
name=data["title_with_entities"]["text"],
|
||||||
|
expires_at=_util.seconds_to_datetime(target.get("expiration_time")),
|
||||||
|
is_expired=target.get("is_expired"),
|
||||||
|
)
|
||||||
480
fbchat/_models/_message.py
Normal file
480
fbchat/_models/_message.py
Normal file
@@ -0,0 +1,480 @@
|
|||||||
|
import attr
|
||||||
|
import datetime
|
||||||
|
import enum
|
||||||
|
from string import Formatter
|
||||||
|
from . import _attachment, _location, _file, _quick_reply, _sticker
|
||||||
|
from .._common import log, attrs_default
|
||||||
|
from .. import _exception, _util
|
||||||
|
from typing import Optional, Mapping, Sequence, Any
|
||||||
|
|
||||||
|
|
||||||
|
class EmojiSize(enum.Enum):
|
||||||
|
"""Used to specify the size of a sent emoji."""
|
||||||
|
|
||||||
|
LARGE = "369239383222810"
|
||||||
|
MEDIUM = "369239343222814"
|
||||||
|
SMALL = "369239263222822"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _from_tags(cls, tags):
|
||||||
|
string_to_emojisize = {
|
||||||
|
"large": cls.LARGE,
|
||||||
|
"medium": cls.MEDIUM,
|
||||||
|
"small": cls.SMALL,
|
||||||
|
"l": cls.LARGE,
|
||||||
|
"m": cls.MEDIUM,
|
||||||
|
"s": cls.SMALL,
|
||||||
|
}
|
||||||
|
for tag in tags or ():
|
||||||
|
data = tag.split(":", 1)
|
||||||
|
if len(data) > 1 and data[0] == "hot_emoji_size":
|
||||||
|
return string_to_emojisize.get(data[1])
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@attrs_default
|
||||||
|
class Mention:
|
||||||
|
"""Represents a ``@mention``.
|
||||||
|
|
||||||
|
>>> fbchat.Mention(thread_id="1234", offset=5, length=2)
|
||||||
|
Mention(thread_id="1234", offset=5, length=2)
|
||||||
|
"""
|
||||||
|
|
||||||
|
#: The thread ID the mention is pointing at
|
||||||
|
thread_id = attr.ib(type=str)
|
||||||
|
#: The character where the mention starts
|
||||||
|
offset = attr.ib(type=int)
|
||||||
|
#: The length of the mention
|
||||||
|
length = attr.ib(type=int)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _from_range(cls, data):
|
||||||
|
# TODO: Parse data["entity"]["__typename"]
|
||||||
|
return cls(
|
||||||
|
# Can be missing
|
||||||
|
thread_id=data["entity"].get("id"),
|
||||||
|
offset=data["offset"],
|
||||||
|
length=data["length"],
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _from_prng(cls, data):
|
||||||
|
return cls(thread_id=data["i"], offset=data["o"], length=data["l"])
|
||||||
|
|
||||||
|
def _to_send_data(self, i):
|
||||||
|
return {
|
||||||
|
"profile_xmd[{}][id]".format(i): self.thread_id,
|
||||||
|
"profile_xmd[{}][offset]".format(i): self.offset,
|
||||||
|
"profile_xmd[{}][length]".format(i): self.length,
|
||||||
|
"profile_xmd[{}][type]".format(i): "p",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Exaustively searched for options by using the list in:
|
||||||
|
# https://unicode.org/emoji/charts/full-emoji-list.html
|
||||||
|
SENDABLE_REACTIONS = ("❤", "😍", "😆", "😮", "😢", "😠", "👍", "👎")
|
||||||
|
|
||||||
|
|
||||||
|
@attrs_default
|
||||||
|
class Message:
|
||||||
|
"""Represents a Facebook message.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> thread = fbchat.User(session=session, id="1234")
|
||||||
|
>>> message = fbchat.Message(thread=thread, id="mid.$XYZ")
|
||||||
|
"""
|
||||||
|
|
||||||
|
#: The thread that this message belongs to.
|
||||||
|
thread = attr.ib()
|
||||||
|
#: The message ID.
|
||||||
|
id = attr.ib(converter=str, type=str)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def session(self):
|
||||||
|
"""The session to use when making requests."""
|
||||||
|
return self.thread.session
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _delete_many(session, message_ids):
|
||||||
|
data = {}
|
||||||
|
for i, id_ in enumerate(message_ids):
|
||||||
|
data["message_ids[{}]".format(i)] = id_
|
||||||
|
j = session._payload_post("/ajax/mercury/delete_messages.php?dpr=1", data)
|
||||||
|
|
||||||
|
def delete(self):
|
||||||
|
"""Delete the message (removes it only for the user).
|
||||||
|
|
||||||
|
If you want to delete multiple messages, please use `Client.delete_messages`.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> message.delete()
|
||||||
|
"""
|
||||||
|
self._delete_many(self.session, [self.id])
|
||||||
|
|
||||||
|
def unsend(self):
|
||||||
|
"""Unsend the message (removes it for everyone).
|
||||||
|
|
||||||
|
The message must to be sent by you, and less than 10 minutes ago.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> message.unsend()
|
||||||
|
"""
|
||||||
|
data = {"message_id": self.id}
|
||||||
|
j = self.session._payload_post("/messaging/unsend_message/?dpr=1", data)
|
||||||
|
|
||||||
|
def react(self, reaction: Optional[str]):
|
||||||
|
"""React to the message, or removes reaction.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
reaction: Reaction emoji to use, or if ``None``, removes reaction.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> message.react("😍")
|
||||||
|
"""
|
||||||
|
data = {
|
||||||
|
"action": "ADD_REACTION" if reaction else "REMOVE_REACTION",
|
||||||
|
"client_mutation_id": "1",
|
||||||
|
"actor_id": self.session.user.id,
|
||||||
|
"message_id": self.id,
|
||||||
|
"reaction": reaction,
|
||||||
|
}
|
||||||
|
data = {
|
||||||
|
"doc_id": 1491398900900362,
|
||||||
|
"variables": _util.json_minimal({"data": data}),
|
||||||
|
}
|
||||||
|
j = self.session._payload_post("/webgraphql/mutation", data)
|
||||||
|
_exception.handle_graphql_errors(j)
|
||||||
|
|
||||||
|
def fetch(self) -> "MessageData":
|
||||||
|
"""Fetch fresh `MessageData` object.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> message = message.fetch()
|
||||||
|
>>> message.text
|
||||||
|
"The message text"
|
||||||
|
"""
|
||||||
|
message_info = self.thread._forced_fetch(self.id).get("message")
|
||||||
|
return MessageData._from_graphql(self.thread, message_info)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def format_mentions(text, *args, **kwargs):
|
||||||
|
"""Like `str.format`, but takes tuples with a thread id and text instead.
|
||||||
|
|
||||||
|
Return a tuple, with the formatted string and relevant mentions.
|
||||||
|
|
||||||
|
>>> Message.format_mentions("Hey {!r}! My name is {}", ("1234", "Peter"), ("4321", "Michael"))
|
||||||
|
("Hey 'Peter'! My name is Michael", [Mention(thread_id=1234, offset=4, length=7), Mention(thread_id=4321, offset=24, length=7)])
|
||||||
|
|
||||||
|
>>> Message.format_mentions("Hey {p}! My name is {}", ("1234", "Michael"), p=("4321", "Peter"))
|
||||||
|
('Hey Peter! My name is Michael', [Mention(thread_id=4321, offset=4, length=5), Mention(thread_id=1234, offset=22, length=7)])
|
||||||
|
"""
|
||||||
|
result = ""
|
||||||
|
mentions = list()
|
||||||
|
offset = 0
|
||||||
|
f = Formatter()
|
||||||
|
field_names = [field_name[1] for field_name in f.parse(text)]
|
||||||
|
automatic = "" in field_names
|
||||||
|
i = 0
|
||||||
|
|
||||||
|
for (literal_text, field_name, format_spec, conversion) in f.parse(text):
|
||||||
|
offset += len(literal_text)
|
||||||
|
result += literal_text
|
||||||
|
|
||||||
|
if field_name is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if field_name == "":
|
||||||
|
field_name = str(i)
|
||||||
|
i += 1
|
||||||
|
elif automatic and field_name.isdigit():
|
||||||
|
raise ValueError(
|
||||||
|
"cannot switch from automatic field numbering to manual field specification"
|
||||||
|
)
|
||||||
|
|
||||||
|
thread_id, name = f.get_field(field_name, args, kwargs)[0]
|
||||||
|
|
||||||
|
if format_spec:
|
||||||
|
name = f.format_field(name, format_spec)
|
||||||
|
if conversion:
|
||||||
|
name = f.convert_field(name, conversion)
|
||||||
|
|
||||||
|
result += name
|
||||||
|
mentions.append(
|
||||||
|
Mention(thread_id=thread_id, offset=offset, length=len(name))
|
||||||
|
)
|
||||||
|
offset += len(name)
|
||||||
|
|
||||||
|
return result, mentions
|
||||||
|
|
||||||
|
|
||||||
|
@attrs_default
|
||||||
|
class MessageSnippet(Message):
|
||||||
|
"""Represents data in a Facebook message snippet.
|
||||||
|
|
||||||
|
Inherits `Message`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
#: ID of the sender
|
||||||
|
author = attr.ib(type=str)
|
||||||
|
#: When the message was sent
|
||||||
|
created_at = attr.ib(type=datetime.datetime)
|
||||||
|
#: The actual message
|
||||||
|
text = attr.ib(type=str)
|
||||||
|
#: A dict with offsets, mapped to the matched text
|
||||||
|
matched_keywords = attr.ib(type=Mapping[int, str])
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _parse(cls, thread, data):
|
||||||
|
return cls(
|
||||||
|
thread=thread,
|
||||||
|
id=data["message_id"],
|
||||||
|
author=data["author"].rstrip("fbid:"),
|
||||||
|
created_at=_util.millis_to_datetime(data["timestamp"]),
|
||||||
|
text=data["body"],
|
||||||
|
matched_keywords={int(k): v for k, v in data["matched_keywords"].items()},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@attrs_default
|
||||||
|
class MessageData(Message):
|
||||||
|
"""Represents data in a Facebook message.
|
||||||
|
|
||||||
|
Inherits `Message`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
#: ID of the sender
|
||||||
|
author = attr.ib(type=str)
|
||||||
|
#: When the message was sent
|
||||||
|
created_at = attr.ib(type=datetime.datetime)
|
||||||
|
#: The actual message
|
||||||
|
text = attr.ib(None, type=Optional[str])
|
||||||
|
#: A list of `Mention` objects
|
||||||
|
mentions = attr.ib(factory=list, type=Sequence[Mention])
|
||||||
|
#: Size of a sent emoji
|
||||||
|
emoji_size = attr.ib(None, type=Optional[EmojiSize])
|
||||||
|
#: Whether the message is read
|
||||||
|
is_read = attr.ib(None, type=Optional[bool])
|
||||||
|
#: People IDs who read the message, only works with `ThreadABC.fetch_messages`
|
||||||
|
read_by = attr.ib(factory=list, type=bool)
|
||||||
|
#: A dictionary with user's IDs as keys, and their reaction as values
|
||||||
|
reactions = attr.ib(factory=dict, type=Mapping[str, str])
|
||||||
|
#: A `Sticker`
|
||||||
|
sticker = attr.ib(None, type=Optional[_sticker.Sticker])
|
||||||
|
#: A list of attachments
|
||||||
|
attachments = attr.ib(factory=list, type=Sequence[_attachment.Attachment])
|
||||||
|
#: A list of `QuickReply`
|
||||||
|
quick_replies = attr.ib(factory=list, type=Sequence[_quick_reply.QuickReply])
|
||||||
|
#: Whether the message is unsent (deleted for everyone)
|
||||||
|
unsent = attr.ib(False, type=Optional[bool])
|
||||||
|
#: Message ID you want to reply to
|
||||||
|
reply_to_id = attr.ib(None, type=Optional[str])
|
||||||
|
#: Replied message
|
||||||
|
replied_to = attr.ib(None, type=Optional[Any])
|
||||||
|
#: Whether the message was forwarded
|
||||||
|
forwarded = attr.ib(False, type=Optional[bool])
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_forwarded_from_tags(tags):
|
||||||
|
if tags is None:
|
||||||
|
return False
|
||||||
|
return any(map(lambda tag: "forward" in tag or "copy" in tag, tags))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_quick_replies(data):
|
||||||
|
if data:
|
||||||
|
data = _util.parse_json(data).get("quick_replies")
|
||||||
|
if isinstance(data, list):
|
||||||
|
return [_quick_reply.graphql_to_quick_reply(q) for q in data]
|
||||||
|
elif isinstance(data, dict):
|
||||||
|
return [_quick_reply.graphql_to_quick_reply(data, is_response=True)]
|
||||||
|
return []
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _from_graphql(cls, thread, data, read_receipts=None):
|
||||||
|
if data.get("message_sender") is None:
|
||||||
|
data["message_sender"] = {}
|
||||||
|
if data.get("message") is None:
|
||||||
|
data["message"] = {}
|
||||||
|
tags = data.get("tags_list")
|
||||||
|
|
||||||
|
created_at = _util.millis_to_datetime(int(data.get("timestamp_precise")))
|
||||||
|
|
||||||
|
attachments = [
|
||||||
|
_file.graphql_to_attachment(attachment)
|
||||||
|
for attachment in data.get("blob_attachments") or ()
|
||||||
|
]
|
||||||
|
unsent = False
|
||||||
|
if data.get("extensible_attachment") is not None:
|
||||||
|
attachment = graphql_to_extensible_attachment(data["extensible_attachment"])
|
||||||
|
if isinstance(attachment, _attachment.UnsentMessage):
|
||||||
|
unsent = True
|
||||||
|
elif attachment:
|
||||||
|
attachments.append(attachment)
|
||||||
|
|
||||||
|
replied_to = None
|
||||||
|
if data.get("replied_to_message") and data["replied_to_message"]["message"]:
|
||||||
|
# data["replied_to_message"]["message"] is None if the message is deleted
|
||||||
|
replied_to = cls._from_graphql(
|
||||||
|
thread, data["replied_to_message"]["message"]
|
||||||
|
)
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
thread=thread,
|
||||||
|
id=str(data["message_id"]),
|
||||||
|
author=str(data["message_sender"]["id"]),
|
||||||
|
created_at=created_at,
|
||||||
|
text=data["message"].get("text"),
|
||||||
|
mentions=[
|
||||||
|
Mention._from_range(m) for m in data["message"].get("ranges") or ()
|
||||||
|
],
|
||||||
|
emoji_size=EmojiSize._from_tags(tags),
|
||||||
|
is_read=not data["unread"] if data.get("unread") is not None else None,
|
||||||
|
read_by=[
|
||||||
|
receipt["actor"]["id"]
|
||||||
|
for receipt in read_receipts or ()
|
||||||
|
if _util.millis_to_datetime(int(receipt["watermark"])) >= created_at
|
||||||
|
],
|
||||||
|
reactions={
|
||||||
|
str(r["user"]["id"]): r["reaction"] for r in data["message_reactions"]
|
||||||
|
},
|
||||||
|
sticker=_sticker.Sticker._from_graphql(data.get("sticker")),
|
||||||
|
attachments=attachments,
|
||||||
|
quick_replies=cls._parse_quick_replies(data.get("platform_xmd_encoded")),
|
||||||
|
unsent=unsent,
|
||||||
|
reply_to_id=replied_to.id if replied_to else None,
|
||||||
|
replied_to=replied_to,
|
||||||
|
forwarded=cls._get_forwarded_from_tags(tags),
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _from_reply(cls, thread, data):
|
||||||
|
tags = data["messageMetadata"].get("tags")
|
||||||
|
metadata = data.get("messageMetadata", {})
|
||||||
|
|
||||||
|
attachments = []
|
||||||
|
unsent = False
|
||||||
|
sticker = None
|
||||||
|
for attachment in data.get("attachments") or ():
|
||||||
|
attachment = _util.parse_json(attachment["mercuryJSON"])
|
||||||
|
if attachment.get("blob_attachment"):
|
||||||
|
attachments.append(
|
||||||
|
_file.graphql_to_attachment(attachment["blob_attachment"])
|
||||||
|
)
|
||||||
|
if attachment.get("extensible_attachment"):
|
||||||
|
extensible_attachment = graphql_to_extensible_attachment(
|
||||||
|
attachment["extensible_attachment"]
|
||||||
|
)
|
||||||
|
if isinstance(extensible_attachment, _attachment.UnsentMessage):
|
||||||
|
unsent = True
|
||||||
|
else:
|
||||||
|
attachments.append(extensible_attachment)
|
||||||
|
if attachment.get("sticker_attachment"):
|
||||||
|
sticker = _sticker.Sticker._from_graphql(
|
||||||
|
attachment["sticker_attachment"]
|
||||||
|
)
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
thread=thread,
|
||||||
|
id=metadata.get("messageId"),
|
||||||
|
author=str(metadata["actorFbId"]),
|
||||||
|
created_at=_util.millis_to_datetime(metadata["timestamp"]),
|
||||||
|
text=data.get("body"),
|
||||||
|
mentions=[
|
||||||
|
Mention._from_prng(m)
|
||||||
|
for m in _util.parse_json(data.get("data", {}).get("prng", "[]"))
|
||||||
|
],
|
||||||
|
emoji_size=EmojiSize._from_tags(tags),
|
||||||
|
sticker=sticker,
|
||||||
|
attachments=attachments,
|
||||||
|
quick_replies=cls._parse_quick_replies(data.get("platform_xmd_encoded")),
|
||||||
|
unsent=unsent,
|
||||||
|
reply_to_id=data["messageReply"]["replyToMessageId"]["id"]
|
||||||
|
if "messageReply" in data
|
||||||
|
else None,
|
||||||
|
forwarded=cls._get_forwarded_from_tags(tags),
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _from_pull(cls, thread, data, author, created_at):
|
||||||
|
metadata = data["messageMetadata"]
|
||||||
|
|
||||||
|
tags = metadata.get("tags")
|
||||||
|
|
||||||
|
mentions = []
|
||||||
|
if data.get("data") and data["data"].get("prng"):
|
||||||
|
try:
|
||||||
|
mentions = [
|
||||||
|
Mention._from_prng(m)
|
||||||
|
for m in _util.parse_json(data["data"]["prng"])
|
||||||
|
]
|
||||||
|
except Exception:
|
||||||
|
log.exception("An exception occured while reading attachments")
|
||||||
|
|
||||||
|
attachments = []
|
||||||
|
unsent = False
|
||||||
|
sticker = None
|
||||||
|
try:
|
||||||
|
for a in data.get("attachments") or ():
|
||||||
|
mercury = a["mercury"]
|
||||||
|
if mercury.get("blob_attachment"):
|
||||||
|
image_metadata = a.get("imageMetadata", {})
|
||||||
|
attach_type = mercury["blob_attachment"]["__typename"]
|
||||||
|
attachment = _file.graphql_to_attachment(
|
||||||
|
mercury["blob_attachment"], a.get("fileSize")
|
||||||
|
)
|
||||||
|
attachments.append(attachment)
|
||||||
|
|
||||||
|
elif mercury.get("sticker_attachment"):
|
||||||
|
sticker = _sticker.Sticker._from_graphql(
|
||||||
|
mercury["sticker_attachment"]
|
||||||
|
)
|
||||||
|
|
||||||
|
elif mercury.get("extensible_attachment"):
|
||||||
|
attachment = graphql_to_extensible_attachment(
|
||||||
|
mercury["extensible_attachment"]
|
||||||
|
)
|
||||||
|
if isinstance(attachment, _attachment.UnsentMessage):
|
||||||
|
unsent = True
|
||||||
|
elif attachment:
|
||||||
|
attachments.append(attachment)
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
log.exception(
|
||||||
|
"An exception occured while reading attachments: {}".format(
|
||||||
|
data["attachments"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
thread=thread,
|
||||||
|
id=metadata["messageId"],
|
||||||
|
author=author,
|
||||||
|
created_at=created_at,
|
||||||
|
text=data.get("body"),
|
||||||
|
mentions=mentions,
|
||||||
|
emoji_size=EmojiSize._from_tags(tags),
|
||||||
|
sticker=sticker,
|
||||||
|
attachments=attachments,
|
||||||
|
unsent=unsent,
|
||||||
|
forwarded=cls._get_forwarded_from_tags(tags),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def graphql_to_extensible_attachment(data):
|
||||||
|
story = data.get("story_attachment")
|
||||||
|
if not story:
|
||||||
|
return None
|
||||||
|
|
||||||
|
target = story.get("target")
|
||||||
|
if not target:
|
||||||
|
return _attachment.UnsentMessage(id=data.get("legacy_attachment_id"))
|
||||||
|
|
||||||
|
_type = target["__typename"]
|
||||||
|
if _type == "MessageLocation":
|
||||||
|
return _location.LocationAttachment._from_graphql(story)
|
||||||
|
elif _type == "MessageLiveLocation":
|
||||||
|
return _location.LiveLocationAttachment._from_graphql(story)
|
||||||
|
elif _type in ["ExternalUrl", "Story"]:
|
||||||
|
return _attachment.ShareAttachment._from_graphql(story)
|
||||||
|
|
||||||
|
return None
|
||||||
212
fbchat/_models/_plan.py
Normal file
212
fbchat/_models/_plan.py
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
import attr
|
||||||
|
import datetime
|
||||||
|
import enum
|
||||||
|
from .._common import attrs_default
|
||||||
|
from .. import _exception, _util, _session
|
||||||
|
|
||||||
|
from typing import Mapping, Sequence, Optional
|
||||||
|
|
||||||
|
|
||||||
|
class GuestStatus(enum.Enum):
|
||||||
|
INVITED = 1
|
||||||
|
GOING = 2
|
||||||
|
DECLINED = 3
|
||||||
|
|
||||||
|
|
||||||
|
ACONTEXT = {
|
||||||
|
"action_history": [
|
||||||
|
{"surface": "messenger_chat_tab", "mechanism": "messenger_composer"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@attrs_default
|
||||||
|
class Plan:
|
||||||
|
"""Base model for plans.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> plan = fbchat.Plan(session=session, id="1234")
|
||||||
|
"""
|
||||||
|
|
||||||
|
#: The session to use when making requests.
|
||||||
|
session = attr.ib(type=_session.Session)
|
||||||
|
#: The plan's unique identifier.
|
||||||
|
id = attr.ib(converter=str, type=str)
|
||||||
|
|
||||||
|
def fetch(self) -> "PlanData":
|
||||||
|
"""Fetch fresh `PlanData` object.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> plan = plan.fetch()
|
||||||
|
>>> plan.title
|
||||||
|
"A plan"
|
||||||
|
"""
|
||||||
|
data = {"event_reminder_id": self.id}
|
||||||
|
j = self.session._payload_post("/ajax/eventreminder", data)
|
||||||
|
return PlanData._from_fetch(self.session, j)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _create(
|
||||||
|
cls,
|
||||||
|
thread,
|
||||||
|
name: str,
|
||||||
|
at: datetime.datetime,
|
||||||
|
location_name: str = None,
|
||||||
|
location_id: str = None,
|
||||||
|
):
|
||||||
|
data = {
|
||||||
|
"event_type": "EVENT",
|
||||||
|
"event_time": _util.datetime_to_seconds(at),
|
||||||
|
"title": name,
|
||||||
|
"thread_id": thread.id,
|
||||||
|
"location_id": location_id or "",
|
||||||
|
"location_name": location_name or "",
|
||||||
|
"acontext": ACONTEXT,
|
||||||
|
}
|
||||||
|
j = thread.session._payload_post("/ajax/eventreminder/create", data)
|
||||||
|
if "error" in j:
|
||||||
|
raise _exception.ExternalError("Failed creating plan", j["error"])
|
||||||
|
|
||||||
|
def edit(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
at: datetime.datetime,
|
||||||
|
location_name: str = None,
|
||||||
|
location_id: str = None,
|
||||||
|
):
|
||||||
|
"""Edit the plan.
|
||||||
|
|
||||||
|
# TODO: Arguments
|
||||||
|
"""
|
||||||
|
data = {
|
||||||
|
"event_reminder_id": self.id,
|
||||||
|
"delete": "false",
|
||||||
|
"date": _util.datetime_to_seconds(at),
|
||||||
|
"location_name": location_name or "",
|
||||||
|
"location_id": location_id or "",
|
||||||
|
"title": name,
|
||||||
|
"acontext": ACONTEXT,
|
||||||
|
}
|
||||||
|
j = self.session._payload_post("/ajax/eventreminder/submit", data)
|
||||||
|
|
||||||
|
def delete(self):
|
||||||
|
"""Delete the plan.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> plan.delete()
|
||||||
|
"""
|
||||||
|
data = {"event_reminder_id": self.id, "delete": "true", "acontext": ACONTEXT}
|
||||||
|
j = self.session._payload_post("/ajax/eventreminder/submit", data)
|
||||||
|
|
||||||
|
def _change_participation(self):
|
||||||
|
data = {
|
||||||
|
"event_reminder_id": self.id,
|
||||||
|
"guest_state": "GOING" if take_part else "DECLINED",
|
||||||
|
"acontext": ACONTEXT,
|
||||||
|
}
|
||||||
|
j = self.session._payload_post("/ajax/eventreminder/rsvp", data)
|
||||||
|
|
||||||
|
def participate(self):
|
||||||
|
"""Set yourself as GOING/participating to the plan.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> plan.participate()
|
||||||
|
"""
|
||||||
|
return self._change_participation(True)
|
||||||
|
|
||||||
|
def decline(self):
|
||||||
|
"""Set yourself as having DECLINED the plan.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> plan.decline()
|
||||||
|
"""
|
||||||
|
return self._change_participation(False)
|
||||||
|
|
||||||
|
|
||||||
|
@attrs_default
|
||||||
|
class PlanData(Plan):
|
||||||
|
"""Represents data about a plan."""
|
||||||
|
|
||||||
|
#: Plan time, only precise down to the minute
|
||||||
|
time = attr.ib(type=datetime.datetime)
|
||||||
|
#: Plan title
|
||||||
|
title = attr.ib(type=str)
|
||||||
|
#: Plan location name
|
||||||
|
location = attr.ib(None, converter=lambda x: x or "", type=Optional[str])
|
||||||
|
#: Plan location ID
|
||||||
|
location_id = attr.ib(None, converter=lambda x: x or "", type=Optional[str])
|
||||||
|
#: ID of the plan creator
|
||||||
|
author_id = attr.ib(None, type=Optional[str])
|
||||||
|
#: `User` ids mapped to their `GuestStatus`
|
||||||
|
guests = attr.ib(None, type=Optional[Mapping[str, GuestStatus]])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def going(self) -> Sequence[str]:
|
||||||
|
"""List of the `User` IDs who will take part in the plan."""
|
||||||
|
return [
|
||||||
|
id_
|
||||||
|
for id_, status in (self.guests or {}).items()
|
||||||
|
if status is GuestStatus.GOING
|
||||||
|
]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def declined(self) -> Sequence[str]:
|
||||||
|
"""List of the `User` IDs who won't take part in the plan."""
|
||||||
|
return [
|
||||||
|
id_
|
||||||
|
for id_, status in (self.guests or {}).items()
|
||||||
|
if status is GuestStatus.DECLINED
|
||||||
|
]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def invited(self) -> Sequence[str]:
|
||||||
|
"""List of the `User` IDs who are invited to the plan."""
|
||||||
|
return [
|
||||||
|
id_
|
||||||
|
for id_, status in (self.guests or {}).items()
|
||||||
|
if status is GuestStatus.INVITED
|
||||||
|
]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _from_pull(cls, session, data):
|
||||||
|
return cls(
|
||||||
|
session=session,
|
||||||
|
id=data.get("event_id"),
|
||||||
|
time=_util.seconds_to_datetime(int(data.get("event_time"))),
|
||||||
|
title=data.get("event_title"),
|
||||||
|
location=data.get("event_location_name"),
|
||||||
|
location_id=data.get("event_location_id"),
|
||||||
|
author_id=data.get("event_creator_id"),
|
||||||
|
guests={
|
||||||
|
x["node"]["id"]: GuestStatus[x["guest_list_state"]]
|
||||||
|
for x in _util.parse_json(data["guest_state_list"])
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _from_fetch(cls, session, data):
|
||||||
|
return cls(
|
||||||
|
session=session,
|
||||||
|
id=data.get("oid"),
|
||||||
|
time=_util.seconds_to_datetime(data.get("event_time")),
|
||||||
|
title=data.get("title"),
|
||||||
|
location=data.get("location_name"),
|
||||||
|
location_id=str(data["location_id"]) if data.get("location_id") else None,
|
||||||
|
author_id=data.get("creator_id"),
|
||||||
|
guests={id_: GuestStatus[s] for id_, s in data["event_members"].items()},
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _from_graphql(cls, session, data):
|
||||||
|
return cls(
|
||||||
|
session=session,
|
||||||
|
id=data.get("id"),
|
||||||
|
time=_util.seconds_to_datetime(data.get("time")),
|
||||||
|
title=data.get("event_title"),
|
||||||
|
location=data.get("location_name"),
|
||||||
|
author_id=data["lightweight_event_creator"].get("id"),
|
||||||
|
guests={
|
||||||
|
x["node"]["id"]: GuestStatus[x["guest_list_state"]]
|
||||||
|
for x in data["event_reminder_members"]["edges"]
|
||||||
|
},
|
||||||
|
)
|
||||||
115
fbchat/_models/_poll.py
Normal file
115
fbchat/_models/_poll.py
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import attr
|
||||||
|
from .._common import attrs_default
|
||||||
|
from .. import _exception, _session
|
||||||
|
from typing import Iterable, Sequence
|
||||||
|
|
||||||
|
|
||||||
|
@attrs_default
|
||||||
|
class PollOption:
|
||||||
|
"""Represents a poll option."""
|
||||||
|
|
||||||
|
#: ID of the poll option
|
||||||
|
id = attr.ib(converter=str, type=str)
|
||||||
|
#: Text of the poll option
|
||||||
|
text = attr.ib(type=str)
|
||||||
|
#: Whether vote when creating or client voted
|
||||||
|
vote = attr.ib(type=bool)
|
||||||
|
#: ID of the users who voted for this poll option
|
||||||
|
voters = attr.ib(type=Sequence[str])
|
||||||
|
#: Votes count
|
||||||
|
votes_count = attr.ib(type=int)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _from_graphql(cls, data):
|
||||||
|
if data.get("viewer_has_voted") is None:
|
||||||
|
vote = False
|
||||||
|
elif isinstance(data["viewer_has_voted"], bool):
|
||||||
|
vote = data["viewer_has_voted"]
|
||||||
|
else:
|
||||||
|
vote = data["viewer_has_voted"] == "true"
|
||||||
|
return cls(
|
||||||
|
id=int(data["id"]),
|
||||||
|
text=data.get("text"),
|
||||||
|
vote=vote,
|
||||||
|
voters=(
|
||||||
|
[m["node"]["id"] for m in data["voters"]["edges"]]
|
||||||
|
if isinstance(data.get("voters"), dict)
|
||||||
|
else data["voters"]
|
||||||
|
),
|
||||||
|
votes_count=(
|
||||||
|
data["voters"]["count"]
|
||||||
|
if isinstance(data.get("voters"), dict)
|
||||||
|
else data["total_count"]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@attrs_default
|
||||||
|
class Poll:
|
||||||
|
"""Represents a poll."""
|
||||||
|
|
||||||
|
#: ID of the poll
|
||||||
|
session = attr.ib(type=_session.Session)
|
||||||
|
#: ID of the poll
|
||||||
|
id = attr.ib(converter=str, type=str)
|
||||||
|
#: The poll's question
|
||||||
|
question = attr.ib(type=str)
|
||||||
|
#: The poll's top few options. The full list can be fetched with `fetch_options`
|
||||||
|
options = attr.ib(type=Sequence[PollOption])
|
||||||
|
#: Options count
|
||||||
|
options_count = attr.ib(type=int)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _from_graphql(cls, session, data):
|
||||||
|
return cls(
|
||||||
|
session=session,
|
||||||
|
id=data["id"],
|
||||||
|
question=data["title"] if data.get("title") else data["text"],
|
||||||
|
options=[PollOption._from_graphql(m) for m in data["options"]],
|
||||||
|
options_count=data["total_count"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def fetch_options(self) -> Sequence[PollOption]:
|
||||||
|
"""Fetch all `PollOption` objects on the poll.
|
||||||
|
|
||||||
|
The result is ordered with options with the most votes first.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> options = poll.fetch_options()
|
||||||
|
>>> options[0].text
|
||||||
|
"An option"
|
||||||
|
"""
|
||||||
|
data = {"question_id": self.id}
|
||||||
|
j = self.session._payload_post("/ajax/mercury/get_poll_options", data)
|
||||||
|
return [PollOption._from_graphql(m) for m in j]
|
||||||
|
|
||||||
|
def set_votes(self, option_ids: Iterable[str], new_options: Iterable[str] = None):
|
||||||
|
"""Update the user's poll vote.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
option_ids: Option ids to vote for / keep voting for
|
||||||
|
new_options: New options to add
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> options = poll.fetch_options()
|
||||||
|
>>> # Add option
|
||||||
|
>>> poll.set_votes([o.id for o in options], new_options=["New option"])
|
||||||
|
>>> # Remove vote from option
|
||||||
|
>>> poll.set_votes([o.id for o in options if o.text != "Option 1"])
|
||||||
|
"""
|
||||||
|
data = {"question_id": self.id}
|
||||||
|
|
||||||
|
for i, option_id in enumerate(option_ids or ()):
|
||||||
|
data["selected_options[{}]".format(i)] = option_id
|
||||||
|
|
||||||
|
for i, option_text in enumerate(new_options or ()):
|
||||||
|
data["new_options[{}]".format(i)] = option_text
|
||||||
|
|
||||||
|
j = self.session._payload_post(
|
||||||
|
"/messaging/group_polling/update_vote/?dpr=1", data
|
||||||
|
)
|
||||||
|
if j.get("status") != "success":
|
||||||
|
raise _exception.ExternalError(
|
||||||
|
"Failed updating poll vote: {}".format(j.get("errorTitle")),
|
||||||
|
j.get("errorMessage"),
|
||||||
|
)
|
||||||
@@ -1,80 +1,63 @@
|
|||||||
# -*- coding: UTF-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
import attr
|
import attr
|
||||||
from ._attachment import Attachment
|
from . import Attachment
|
||||||
|
from .._common import attrs_default
|
||||||
|
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
|
||||||
@attr.s(cmp=False)
|
@attrs_default
|
||||||
class QuickReply(object):
|
class QuickReply:
|
||||||
"""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, type=Any)
|
||||||
#: External payload for responses
|
#: External payload for responses
|
||||||
external_payload = attr.ib(None, init=False)
|
external_payload = attr.ib(None, type=Any)
|
||||||
#: Additional data
|
#: Additional data
|
||||||
data = attr.ib(None)
|
data = attr.ib(None, type=Any)
|
||||||
#: Whether it's a response for a quick reply
|
#: Whether it's a response for a quick reply
|
||||||
is_response = attr.ib(False)
|
is_response = attr.ib(False, type=bool)
|
||||||
|
|
||||||
|
|
||||||
@attr.s(cmp=False, init=False)
|
@attrs_default
|
||||||
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, type=Optional[str])
|
||||||
#: URL of the quick reply image (optional)
|
#: URL of the quick reply image
|
||||||
image_url = attr.ib(None)
|
image_url = attr.ib(None, type=Optional[str])
|
||||||
#: Type of the quick reply
|
#: Type of the quick reply
|
||||||
_type = "text"
|
_type = "text"
|
||||||
|
|
||||||
def __init__(self, title=None, image_url=None, **kwargs):
|
|
||||||
super(QuickReplyText, self).__init__(**kwargs)
|
|
||||||
self.title = title
|
|
||||||
self.image_url = image_url
|
|
||||||
|
|
||||||
|
@attrs_default
|
||||||
@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"
|
||||||
|
|
||||||
def __init__(self, **kwargs):
|
|
||||||
super(QuickReplyLocation, self).__init__(**kwargs)
|
|
||||||
self.is_response = False
|
|
||||||
|
|
||||||
|
@attrs_default
|
||||||
@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
|
||||||
image_url = attr.ib(None)
|
image_url = attr.ib(None, type=Optional[str])
|
||||||
#: Type of the quick reply
|
#: Type of the quick reply
|
||||||
_type = "user_phone_number"
|
_type = "user_phone_number"
|
||||||
|
|
||||||
def __init__(self, image_url=None, **kwargs):
|
|
||||||
super(QuickReplyPhoneNumber, self).__init__(**kwargs)
|
|
||||||
self.image_url = image_url
|
|
||||||
|
|
||||||
|
@attrs_default
|
||||||
@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
|
||||||
image_url = attr.ib(None)
|
image_url = attr.ib(None, type=Optional[str])
|
||||||
#: Type of the quick reply
|
#: Type of the quick reply
|
||||||
_type = "user_email"
|
_type = "user_email"
|
||||||
|
|
||||||
def __init__(self, image_url=None, **kwargs):
|
|
||||||
super(QuickReplyEmail, self).__init__(**kwargs)
|
|
||||||
self.image_url = image_url
|
|
||||||
|
|
||||||
|
|
||||||
def graphql_to_quick_reply(q, is_response=False):
|
def graphql_to_quick_reply(q, is_response=False):
|
||||||
data = dict()
|
data = dict()
|
||||||
57
fbchat/_models/_sticker.py
Normal file
57
fbchat/_models/_sticker.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import attr
|
||||||
|
from . import Image, Attachment
|
||||||
|
from .._common import attrs_default
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
@attrs_default
|
||||||
|
class Sticker(Attachment):
|
||||||
|
"""Represents a Facebook sticker that has been sent to a thread as an attachment."""
|
||||||
|
|
||||||
|
#: The sticker-pack's ID
|
||||||
|
pack = attr.ib(None, type=Optional[str])
|
||||||
|
#: Whether the sticker is animated
|
||||||
|
is_animated = attr.ib(False, type=bool)
|
||||||
|
|
||||||
|
# If the sticker is animated, the following should be present
|
||||||
|
#: URL to a medium spritemap
|
||||||
|
medium_sprite_image = attr.ib(None, type=Optional[str])
|
||||||
|
#: URL to a large spritemap
|
||||||
|
large_sprite_image = attr.ib(None, type=Optional[str])
|
||||||
|
#: The amount of frames present in the spritemap pr. row
|
||||||
|
frames_per_row = attr.ib(None, type=Optional[int])
|
||||||
|
#: The amount of frames present in the spritemap pr. column
|
||||||
|
frames_per_col = attr.ib(None, type=Optional[int])
|
||||||
|
#: The total amount of frames in the spritemap
|
||||||
|
frame_count = attr.ib(None, type=Optional[int])
|
||||||
|
#: The frame rate the spritemap is intended to be played in
|
||||||
|
frame_rate = attr.ib(None, type=Optional[int])
|
||||||
|
|
||||||
|
#: The sticker's image
|
||||||
|
image = attr.ib(None, type=Optional[Image])
|
||||||
|
#: The sticker's label/name
|
||||||
|
label = attr.ib(None, type=Optional[str])
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _from_graphql(cls, data):
|
||||||
|
if not data:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
id=data["id"],
|
||||||
|
pack=data["pack"].get("id") if data.get("pack") else None,
|
||||||
|
is_animated=bool(data.get("sprite_image")),
|
||||||
|
medium_sprite_image=data["sprite_image"].get("uri")
|
||||||
|
if data.get("sprite_image")
|
||||||
|
else None,
|
||||||
|
large_sprite_image=data["sprite_image_2x"].get("uri")
|
||||||
|
if data.get("sprite_image_2x")
|
||||||
|
else None,
|
||||||
|
frames_per_row=data.get("frames_per_row"),
|
||||||
|
frames_per_col=data.get("frames_per_column"),
|
||||||
|
frame_count=data.get("frame_count"),
|
||||||
|
frame_rate=data.get("frame_rate"),
|
||||||
|
image=Image._from_url_or_none(data),
|
||||||
|
label=data["label"] if data.get("label") else None,
|
||||||
|
)
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
# -*- coding: UTF-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
import attr
|
|
||||||
from . import _plan
|
|
||||||
from ._thread import ThreadType, Thread
|
|
||||||
|
|
||||||
|
|
||||||
@attr.s(cmp=False, init=False)
|
|
||||||
class Page(Thread):
|
|
||||||
"""Represents a Facebook page. Inherits `Thread`"""
|
|
||||||
|
|
||||||
#: The page's custom url
|
|
||||||
url = attr.ib(None)
|
|
||||||
#: The name of the page's location city
|
|
||||||
city = attr.ib(None)
|
|
||||||
#: Amount of likes the page has
|
|
||||||
likes = attr.ib(None)
|
|
||||||
#: Some extra information about the page
|
|
||||||
sub_title = attr.ib(None)
|
|
||||||
#: The page's category
|
|
||||||
category = attr.ib(None)
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
uid,
|
|
||||||
url=None,
|
|
||||||
city=None,
|
|
||||||
likes=None,
|
|
||||||
sub_title=None,
|
|
||||||
category=None,
|
|
||||||
**kwargs
|
|
||||||
):
|
|
||||||
super(Page, self).__init__(ThreadType.PAGE, uid, **kwargs)
|
|
||||||
self.url = url
|
|
||||||
self.city = city
|
|
||||||
self.likes = likes
|
|
||||||
self.sub_title = sub_title
|
|
||||||
self.category = category
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _from_graphql(cls, data):
|
|
||||||
if data.get("profile_picture") is None:
|
|
||||||
data["profile_picture"] = {}
|
|
||||||
if data.get("city") is None:
|
|
||||||
data["city"] = {}
|
|
||||||
plan = None
|
|
||||||
if data.get("event_reminders") and data["event_reminders"].get("nodes"):
|
|
||||||
plan = _plan.Plan._from_graphql(data["event_reminders"]["nodes"][0])
|
|
||||||
|
|
||||||
return cls(
|
|
||||||
data["id"],
|
|
||||||
url=data.get("url"),
|
|
||||||
city=data.get("city").get("name"),
|
|
||||||
category=data.get("category_type"),
|
|
||||||
photo=data["profile_picture"].get("uri"),
|
|
||||||
name=data.get("name"),
|
|
||||||
message_count=data.get("messages_count"),
|
|
||||||
plan=plan,
|
|
||||||
)
|
|
||||||
103
fbchat/_plan.py
103
fbchat/_plan.py
@@ -1,103 +0,0 @@
|
|||||||
# -*- coding: UTF-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
import attr
|
|
||||||
import json
|
|
||||||
from ._core import Enum
|
|
||||||
|
|
||||||
|
|
||||||
class GuestStatus(Enum):
|
|
||||||
INVITED = 1
|
|
||||||
GOING = 2
|
|
||||||
DECLINED = 3
|
|
||||||
|
|
||||||
|
|
||||||
@attr.s(cmp=False)
|
|
||||||
class Plan(object):
|
|
||||||
"""Represents a plan"""
|
|
||||||
|
|
||||||
#: ID of the plan
|
|
||||||
uid = attr.ib(None, init=False)
|
|
||||||
#: Plan time (unix time stamp), only precise down to the minute
|
|
||||||
time = attr.ib(converter=int)
|
|
||||||
#: Plan title
|
|
||||||
title = attr.ib()
|
|
||||||
#: Plan location name
|
|
||||||
location = attr.ib(None, converter=lambda x: x or "")
|
|
||||||
#: Plan location ID
|
|
||||||
location_id = attr.ib(None, converter=lambda x: x or "")
|
|
||||||
#: ID of the plan creator
|
|
||||||
author_id = attr.ib(None, init=False)
|
|
||||||
#: Dict of `User` IDs mapped to their `GuestStatus`
|
|
||||||
guests = attr.ib(None, init=False)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def going(self):
|
|
||||||
"""List of the `User` IDs who will take part in the plan."""
|
|
||||||
return [
|
|
||||||
id_
|
|
||||||
for id_, status in (self.guests or {}).items()
|
|
||||||
if status is GuestStatus.GOING
|
|
||||||
]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def declined(self):
|
|
||||||
"""List of the `User` IDs who won't take part in the plan."""
|
|
||||||
return [
|
|
||||||
id_
|
|
||||||
for id_, status in (self.guests or {}).items()
|
|
||||||
if status is GuestStatus.DECLINED
|
|
||||||
]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def invited(self):
|
|
||||||
"""List of the `User` IDs who are invited to the plan."""
|
|
||||||
return [
|
|
||||||
id_
|
|
||||||
for id_, status in (self.guests or {}).items()
|
|
||||||
if status is GuestStatus.INVITED
|
|
||||||
]
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _from_pull(cls, data):
|
|
||||||
rtn = cls(
|
|
||||||
time=data.get("event_time"),
|
|
||||||
title=data.get("event_title"),
|
|
||||||
location=data.get("event_location_name"),
|
|
||||||
location_id=data.get("event_location_id"),
|
|
||||||
)
|
|
||||||
rtn.uid = data.get("event_id")
|
|
||||||
rtn.author_id = data.get("event_creator_id")
|
|
||||||
rtn.guests = {
|
|
||||||
x["node"]["id"]: GuestStatus[x["guest_list_state"]]
|
|
||||||
for x in json.loads(data["guest_state_list"])
|
|
||||||
}
|
|
||||||
return rtn
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _from_fetch(cls, data):
|
|
||||||
rtn = cls(
|
|
||||||
time=data.get("event_time"),
|
|
||||||
title=data.get("title"),
|
|
||||||
location=data.get("location_name"),
|
|
||||||
location_id=str(data["location_id"]) if data.get("location_id") else None,
|
|
||||||
)
|
|
||||||
rtn.uid = data.get("oid")
|
|
||||||
rtn.author_id = data.get("creator_id")
|
|
||||||
rtn.guests = {id_: GuestStatus[s] for id_, s in data["event_members"].items()}
|
|
||||||
return rtn
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _from_graphql(cls, data):
|
|
||||||
rtn = cls(
|
|
||||||
time=data.get("time"),
|
|
||||||
title=data.get("event_title"),
|
|
||||||
location=data.get("location_name"),
|
|
||||||
)
|
|
||||||
rtn.uid = data.get("id")
|
|
||||||
rtn.author_id = data["lightweight_event_creator"].get("id")
|
|
||||||
rtn.guests = {
|
|
||||||
x["node"]["id"]: GuestStatus[x["guest_list_state"]]
|
|
||||||
for x in data["event_reminder_members"]["edges"]
|
|
||||||
}
|
|
||||||
return rtn
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
# -*- coding: UTF-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
import attr
|
|
||||||
|
|
||||||
|
|
||||||
@attr.s(cmp=False)
|
|
||||||
class Poll(object):
|
|
||||||
"""Represents a poll"""
|
|
||||||
|
|
||||||
#: Title of the poll
|
|
||||||
title = attr.ib()
|
|
||||||
#: List of :class:`PollOption`, can be fetched with :func:`fbchat.Client.fetchPollOptions`
|
|
||||||
options = attr.ib()
|
|
||||||
#: Options count
|
|
||||||
options_count = attr.ib(None)
|
|
||||||
#: ID of the poll
|
|
||||||
uid = attr.ib(None)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _from_graphql(cls, data):
|
|
||||||
return cls(
|
|
||||||
uid=int(data["id"]),
|
|
||||||
title=data.get("title") if data.get("title") else data.get("text"),
|
|
||||||
options=[PollOption._from_graphql(m) for m in data.get("options")],
|
|
||||||
options_count=data.get("total_count"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@attr.s(cmp=False)
|
|
||||||
class PollOption(object):
|
|
||||||
"""Represents a poll option"""
|
|
||||||
|
|
||||||
#: Text of the poll option
|
|
||||||
text = attr.ib()
|
|
||||||
#: Whether vote when creating or client voted
|
|
||||||
vote = attr.ib(False)
|
|
||||||
#: ID of the users who voted for this poll option
|
|
||||||
voters = attr.ib(None)
|
|
||||||
#: Votes count
|
|
||||||
votes_count = attr.ib(None)
|
|
||||||
#: ID of the poll option
|
|
||||||
uid = attr.ib(None)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _from_graphql(cls, data):
|
|
||||||
if data.get("viewer_has_voted") is None:
|
|
||||||
vote = None
|
|
||||||
elif isinstance(data["viewer_has_voted"], bool):
|
|
||||||
vote = data["viewer_has_voted"]
|
|
||||||
else:
|
|
||||||
vote = data["viewer_has_voted"] == "true"
|
|
||||||
return cls(
|
|
||||||
uid=int(data["id"]),
|
|
||||||
text=data.get("text"),
|
|
||||||
vote=vote,
|
|
||||||
voters=(
|
|
||||||
[m.get("node").get("id") for m in data.get("voters").get("edges")]
|
|
||||||
if isinstance(data.get("voters"), dict)
|
|
||||||
else data.get("voters")
|
|
||||||
),
|
|
||||||
votes_count=(
|
|
||||||
data.get("voters").get("count")
|
|
||||||
if isinstance(data.get("voters"), dict)
|
|
||||||
else data.get("total_count")
|
|
||||||
),
|
|
||||||
)
|
|
||||||
584
fbchat/_session.py
Normal file
584
fbchat/_session.py
Normal file
@@ -0,0 +1,584 @@
|
|||||||
|
import attr
|
||||||
|
import datetime
|
||||||
|
import requests
|
||||||
|
import random
|
||||||
|
import re
|
||||||
|
import json
|
||||||
|
|
||||||
|
# TODO: Only import when required
|
||||||
|
# Or maybe just replace usage with `html.parser`?
|
||||||
|
import bs4
|
||||||
|
|
||||||
|
from ._common import log, kw_only
|
||||||
|
from . import _graphql, _util, _exception
|
||||||
|
|
||||||
|
from typing import Optional, Mapping, Callable, Any
|
||||||
|
|
||||||
|
|
||||||
|
SERVER_JS_DEFINE_REGEX = re.compile(
|
||||||
|
r'(?:"ServerJS".{,100}\.handle\({.*"define":)'
|
||||||
|
r'|(?:ServerJS.{,100}\.handleWithCustomApplyEach\(ScheduledApplyEach,{.*"define":)'
|
||||||
|
r'|(?:require\("ServerJSDefine"\)\)?\.handleDefines\()'
|
||||||
|
r'|(?:"require":\[\["ScheduledServerJS".{,100}"define":)'
|
||||||
|
)
|
||||||
|
SERVER_JS_DEFINE_JSON_DECODER = json.JSONDecoder()
|
||||||
|
|
||||||
|
|
||||||
|
def parse_server_js_define(html: str) -> Mapping[str, Any]:
|
||||||
|
"""Parse ``ServerJSDefine`` entries from a HTML document."""
|
||||||
|
# Find points where we should start parsing
|
||||||
|
define_splits = SERVER_JS_DEFINE_REGEX.split(html)
|
||||||
|
|
||||||
|
# TODO: Extract jsmods "require" and "define" from `bigPipe.onPageletArrive`?
|
||||||
|
|
||||||
|
# Skip leading entry
|
||||||
|
_, *define_splits = define_splits
|
||||||
|
|
||||||
|
rtn = []
|
||||||
|
if not define_splits:
|
||||||
|
raise _exception.ParseError("Could not find any ServerJSDefine", data=html)
|
||||||
|
if len(define_splits) < 2:
|
||||||
|
raise _exception.ParseError("Could not find enough ServerJSDefine", data=html)
|
||||||
|
# Parse entries (should be two)
|
||||||
|
for entry in define_splits:
|
||||||
|
try:
|
||||||
|
parsed, _ = SERVER_JS_DEFINE_JSON_DECODER.raw_decode(entry, idx=0)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
raise _exception.ParseError("Invalid ServerJSDefine", data=entry) from e
|
||||||
|
if not isinstance(parsed, list):
|
||||||
|
raise _exception.ParseError("Invalid ServerJSDefine", data=parsed)
|
||||||
|
rtn.extend(parsed)
|
||||||
|
|
||||||
|
# Convert to a dict
|
||||||
|
return _util.get_jsmods_define(rtn)
|
||||||
|
|
||||||
|
|
||||||
|
def base36encode(number: int) -> str:
|
||||||
|
"""Convert from Base10 to Base36."""
|
||||||
|
# Taken from https://en.wikipedia.org/wiki/Base36#Python_implementation
|
||||||
|
chars = "0123456789abcdefghijklmnopqrstuvwxyz"
|
||||||
|
|
||||||
|
sign = "-" if number < 0 else ""
|
||||||
|
number = abs(number)
|
||||||
|
result = ""
|
||||||
|
|
||||||
|
while number > 0:
|
||||||
|
number, remainder = divmod(number, 36)
|
||||||
|
result = chars[remainder] + result
|
||||||
|
|
||||||
|
return sign + result
|
||||||
|
|
||||||
|
|
||||||
|
def prefix_url(url: str) -> str:
|
||||||
|
if url.startswith("/"):
|
||||||
|
return "https://www.messenger.com" + url
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
def generate_message_id(now: datetime.datetime, client_id: str) -> str:
|
||||||
|
k = _util.datetime_to_millis(now)
|
||||||
|
l = int(random.random() * 4294967295)
|
||||||
|
return "<{}:{}-{}@mail.projektitan.com>".format(k, l, client_id)
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_id(session: requests.Session) -> str:
|
||||||
|
# TODO: Optimize this `.get_dict()` call!
|
||||||
|
cookies = session.cookies.get_dict()
|
||||||
|
rtn = cookies.get("c_user")
|
||||||
|
if rtn is None:
|
||||||
|
raise _exception.ParseError("Could not find user id", data=cookies)
|
||||||
|
return str(rtn)
|
||||||
|
|
||||||
|
|
||||||
|
def session_factory() -> requests.Session:
|
||||||
|
from . import __version__
|
||||||
|
|
||||||
|
session = requests.session()
|
||||||
|
# Override Facebook's locale detection during the login process.
|
||||||
|
# The locale is only used when giving errors back to the user, so giving the errors
|
||||||
|
# back in English makes it easier for users to report.
|
||||||
|
session.cookies = session.cookies = requests.cookies.merge_cookies(
|
||||||
|
session.cookies, {"locale": "en_US"}
|
||||||
|
)
|
||||||
|
session.headers["Referer"] = "https://www.messenger.com/"
|
||||||
|
# We won't try to set a fake user agent to mask our presence!
|
||||||
|
# Facebook allows us access anyhow, and it makes our motives clearer:
|
||||||
|
# We're not trying to cheat Facebook, we simply want to access their service
|
||||||
|
session.headers["User-Agent"] = "fbchat/{}".format(__version__)
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
def login_cookies(at: datetime.datetime):
|
||||||
|
return {"act": "{}/0".format(_util.datetime_to_millis(at))}
|
||||||
|
|
||||||
|
|
||||||
|
def client_id_factory() -> str:
|
||||||
|
return hex(int(random.random() * 2**31))[2:]
|
||||||
|
|
||||||
|
|
||||||
|
def find_form_request(html: str):
|
||||||
|
soup = bs4.BeautifulSoup(html, "html.parser", parse_only=bs4.SoupStrainer("form"))
|
||||||
|
|
||||||
|
form = soup.form
|
||||||
|
if not form:
|
||||||
|
raise _exception.ParseError("Could not find form to submit", data=html)
|
||||||
|
|
||||||
|
url = form.get("action")
|
||||||
|
if not url:
|
||||||
|
raise _exception.ParseError("Could not find url to submit to", data=form)
|
||||||
|
|
||||||
|
# From what I've seen, it'll always do this!
|
||||||
|
if url.startswith("/"):
|
||||||
|
url = "https://www.facebook.com" + url
|
||||||
|
|
||||||
|
# It's okay to set missing values to something crap, the values are localized, and
|
||||||
|
# hence are not available in the raw HTML
|
||||||
|
data = {
|
||||||
|
x["name"]: x.get("value", "[missing]")
|
||||||
|
for x in form.find_all(["input", "button"])
|
||||||
|
}
|
||||||
|
return url, data
|
||||||
|
|
||||||
|
|
||||||
|
def two_factor_helper(session: requests.Session, r, on_2fa_callback):
|
||||||
|
url, data = find_form_request(r.content.decode("utf-8"))
|
||||||
|
|
||||||
|
# You don't have to type a code if your device is already saved
|
||||||
|
# Repeats if you get the code wrong
|
||||||
|
while "approvals_code" in data:
|
||||||
|
data["approvals_code"] = on_2fa_callback()
|
||||||
|
log.info("Submitting 2FA code")
|
||||||
|
r = session.post(
|
||||||
|
url, data=data, allow_redirects=False, cookies=login_cookies(_util.now())
|
||||||
|
)
|
||||||
|
log.debug("2FA location: %s", r.headers.get("Location"))
|
||||||
|
url, data = find_form_request(r.content.decode("utf-8"))
|
||||||
|
|
||||||
|
# TODO: Can be missing if checkup flow was done on another device in the meantime?
|
||||||
|
if "name_action_selected" in data:
|
||||||
|
data["name_action_selected"] = "save_device"
|
||||||
|
log.info("Saving browser")
|
||||||
|
r = session.post(
|
||||||
|
url, data=data, allow_redirects=False, cookies=login_cookies(_util.now())
|
||||||
|
)
|
||||||
|
log.debug("2FA location: %s", r.headers.get("Location"))
|
||||||
|
url = r.headers.get("Location")
|
||||||
|
if url and url.startswith("https://www.messenger.com/login/auth_token/"):
|
||||||
|
return url
|
||||||
|
url, data = find_form_request(r.content.decode("utf-8"))
|
||||||
|
|
||||||
|
log.info("Starting Facebook checkup flow")
|
||||||
|
r = session.post(
|
||||||
|
url, data=data, allow_redirects=False, cookies=login_cookies(_util.now())
|
||||||
|
)
|
||||||
|
log.debug("2FA location: %s", r.headers.get("Location"))
|
||||||
|
|
||||||
|
url, data = find_form_request(r.content.decode("utf-8"))
|
||||||
|
if "verification_method" in data:
|
||||||
|
raise _exception.NotLoggedIn(
|
||||||
|
"Your account is locked, and you need to log in using a browser, and verify it there!"
|
||||||
|
)
|
||||||
|
if "submit[This was me]" not in data or "submit[This wasn't me]" not in data:
|
||||||
|
raise _exception.ParseError("Could not fill out form properly (2)", data=data)
|
||||||
|
data["submit[This was me]"] = "[any value]"
|
||||||
|
del data["submit[This wasn't me]"]
|
||||||
|
log.info("Verifying login attempt")
|
||||||
|
r = session.post(
|
||||||
|
url, data=data, allow_redirects=False, cookies=login_cookies(_util.now())
|
||||||
|
)
|
||||||
|
log.debug("2FA location: %s", r.headers.get("Location"))
|
||||||
|
|
||||||
|
url, data = find_form_request(r.content.decode("utf-8"))
|
||||||
|
if "name_action_selected" not in data:
|
||||||
|
raise _exception.ParseError("Could not fill out form properly (3)", data=data)
|
||||||
|
data["name_action_selected"] = "save_device"
|
||||||
|
log.info("Saving device again")
|
||||||
|
r = session.post(
|
||||||
|
url, data=data, allow_redirects=False, cookies=login_cookies(_util.now())
|
||||||
|
)
|
||||||
|
log.debug("2FA location: %s", r.headers.get("Location"))
|
||||||
|
return r.headers.get("Location")
|
||||||
|
|
||||||
|
|
||||||
|
def get_error_data(html: str) -> Optional[str]:
|
||||||
|
"""Get error message from a request."""
|
||||||
|
soup = bs4.BeautifulSoup(
|
||||||
|
html, "html.parser", parse_only=bs4.SoupStrainer("form", id="login_form")
|
||||||
|
)
|
||||||
|
# Attempt to extract and format the error string
|
||||||
|
return " ".join(list(soup.stripped_strings)[1:3]) or None
|
||||||
|
|
||||||
|
|
||||||
|
def get_fb_dtsg(define) -> Optional[str]:
|
||||||
|
if "DTSGInitData" in define:
|
||||||
|
return define["DTSGInitData"]["token"]
|
||||||
|
elif "DTSGInitialData" in define:
|
||||||
|
return define["DTSGInitialData"]["token"]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@attr.s(slots=True, kw_only=kw_only, repr=False, eq=False)
|
||||||
|
class Session:
|
||||||
|
"""Stores and manages state required for most Facebook requests.
|
||||||
|
|
||||||
|
This is the main class, which is used to login to Facebook.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_user_id = attr.ib(type=str)
|
||||||
|
_fb_dtsg = attr.ib(type=str)
|
||||||
|
_revision = attr.ib(type=int)
|
||||||
|
_session = attr.ib(factory=session_factory, type=requests.Session)
|
||||||
|
_counter = attr.ib(0, type=int)
|
||||||
|
_client_id = attr.ib(factory=client_id_factory, type=str)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def user(self):
|
||||||
|
"""The logged in user."""
|
||||||
|
from . import _threads
|
||||||
|
|
||||||
|
# TODO: Consider caching the result
|
||||||
|
|
||||||
|
return _threads.User(session=self, id=self._user_id)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
# An alternative repr, to illustrate that you can't create the class directly
|
||||||
|
return "<fbchat.Session user_id={}>".format(self._user_id)
|
||||||
|
|
||||||
|
def _get_params(self):
|
||||||
|
self._counter += 1 # TODO: Make this operation atomic / thread-safe
|
||||||
|
return {
|
||||||
|
"__a": 1,
|
||||||
|
"__req": base36encode(self._counter),
|
||||||
|
"__rev": self._revision,
|
||||||
|
"fb_dtsg": self._fb_dtsg,
|
||||||
|
}
|
||||||
|
|
||||||
|
# TODO: Add ability to load previous cookies in here, to avoid 2fa flow
|
||||||
|
@classmethod
|
||||||
|
def login(
|
||||||
|
cls, email: str, password: str, on_2fa_callback: Callable[[], int] = None
|
||||||
|
):
|
||||||
|
"""Login the user, using ``email`` and ``password``.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
email: Facebook ``email``, ``id`` or ``phone number``
|
||||||
|
password: Facebook account password
|
||||||
|
on_2fa_callback: Function that will be called, in case a two factor
|
||||||
|
authentication code is needed. This should return the requested code.
|
||||||
|
|
||||||
|
Tested using SMS and authentication applications. If you have both
|
||||||
|
enabled, you might not receive an SMS code, and you'll have to use the
|
||||||
|
authentication application.
|
||||||
|
|
||||||
|
Note: Facebook limits the amount of codes they will give you, so if you
|
||||||
|
don't receive a code, be patient, and try again later!
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> import fbchat
|
||||||
|
>>> import getpass
|
||||||
|
>>> session = fbchat.Session.login(
|
||||||
|
... input("Email: "),
|
||||||
|
... getpass.getpass(),
|
||||||
|
... on_2fa_callback=lambda: input("2FA Code: ")
|
||||||
|
... )
|
||||||
|
Email: abc@gmail.com
|
||||||
|
Password: ****
|
||||||
|
2FA Code: 123456
|
||||||
|
>>> session.user.id
|
||||||
|
"1234"
|
||||||
|
"""
|
||||||
|
session = session_factory()
|
||||||
|
|
||||||
|
data = {
|
||||||
|
# "jazoest": "2754",
|
||||||
|
# "lsd": "AVqqqRUa",
|
||||||
|
"initial_request_id": "x", # any, just has to be present
|
||||||
|
# "timezone": "-120",
|
||||||
|
# "lgndim": "eyJ3IjoxNDQwLCJoIjo5MDAsImF3IjoxNDQwLCJhaCI6ODc3LCJjIjoyNH0=",
|
||||||
|
# "lgnrnd": "044039_RGm9",
|
||||||
|
"lgnjs": "n",
|
||||||
|
"email": email,
|
||||||
|
"pass": password,
|
||||||
|
"login": "1",
|
||||||
|
"persistent": "1", # Changes the cookie type to have a long "expires"
|
||||||
|
"default_persistent": "0",
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Should hit a redirect to https://www.messenger.com/
|
||||||
|
# If this does happen, the session is logged in!
|
||||||
|
r = session.post(
|
||||||
|
"https://www.messenger.com/login/password/",
|
||||||
|
data=data,
|
||||||
|
allow_redirects=False,
|
||||||
|
cookies=login_cookies(_util.now()),
|
||||||
|
headers={
|
||||||
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.106 Safari/537.36",
|
||||||
|
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9",
|
||||||
|
"accept-language": "en-HU,en;q=0.9,hu-HU;q=0.8,hu;q=0.7,en-US;q=0.6",
|
||||||
|
"cache-control": "max-age=0",
|
||||||
|
"origin": "https://www.messenger.com",
|
||||||
|
"referer": "https://www.messenger.com/login/",
|
||||||
|
"sec-ch-ua": '" Not;A Brand";v="99", "Google Chrome";v="91", "Chromium";v="91"',
|
||||||
|
"sec-ch-ua-mobile": "?0",
|
||||||
|
"sec-fetch-dest": "document",
|
||||||
|
"sec-fetch-mode": "navigate",
|
||||||
|
"sec-fetch-site": "same-origin",
|
||||||
|
"sec-fetch-user": "?1",
|
||||||
|
"upgrade-insecure-requests": "1",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except requests.RequestException as e:
|
||||||
|
_exception.handle_requests_error(e)
|
||||||
|
_exception.handle_http_error(r.status_code)
|
||||||
|
|
||||||
|
url = r.headers.get("Location")
|
||||||
|
|
||||||
|
# We weren't redirected, hence the email or password was wrong
|
||||||
|
if not url:
|
||||||
|
error = get_error_data(r.content.decode("utf-8"))
|
||||||
|
raise _exception.NotLoggedIn(error)
|
||||||
|
|
||||||
|
if "checkpoint" in url:
|
||||||
|
if not on_2fa_callback:
|
||||||
|
raise _exception.NotLoggedIn(
|
||||||
|
"2FA code required! Please supply `on_2fa_callback` to .login"
|
||||||
|
)
|
||||||
|
# Get a facebook.com/checkpoint/start url that handles the 2FA flow
|
||||||
|
# This probably works differently for Messenger-only accounts
|
||||||
|
url = _util.get_url_parameter(url, "next")
|
||||||
|
if not url.startswith("https://www.facebook.com/checkpoint/start/"):
|
||||||
|
raise _exception.ParseError("Failed 2fa flow (1)", data=url)
|
||||||
|
|
||||||
|
r = session.get(
|
||||||
|
url, allow_redirects=False, cookies=login_cookies(_util.now())
|
||||||
|
)
|
||||||
|
url = r.headers.get("Location")
|
||||||
|
if not url or not url.startswith("https://www.facebook.com/checkpoint/"):
|
||||||
|
raise _exception.ParseError("Failed 2fa flow (2)", data=url)
|
||||||
|
|
||||||
|
r = session.get(
|
||||||
|
url, allow_redirects=False, cookies=login_cookies(_util.now())
|
||||||
|
)
|
||||||
|
url = two_factor_helper(session, r, on_2fa_callback)
|
||||||
|
|
||||||
|
if not url.startswith("https://www.messenger.com/login/auth_token/"):
|
||||||
|
raise _exception.ParseError("Failed 2fa flow (3)", data=url)
|
||||||
|
|
||||||
|
r = session.get(
|
||||||
|
url, allow_redirects=False, cookies=login_cookies(_util.now())
|
||||||
|
)
|
||||||
|
url = r.headers.get("Location")
|
||||||
|
|
||||||
|
if url != "https://www.messenger.com/":
|
||||||
|
error = get_error_data(r.content.decode("utf-8"))
|
||||||
|
raise _exception.NotLoggedIn("Failed logging in: {}, {}".format(url, error))
|
||||||
|
|
||||||
|
try:
|
||||||
|
return cls._from_session(session=session)
|
||||||
|
except _exception.NotLoggedIn as e:
|
||||||
|
raise _exception.ParseError("Failed loading session", data=r) from e
|
||||||
|
|
||||||
|
def is_logged_in(self) -> bool:
|
||||||
|
"""Send a request to Facebook to check the login status.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Whether the user is still logged in
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> assert session.is_logged_in()
|
||||||
|
"""
|
||||||
|
# Send a request to the login url, to see if we're directed to the home page
|
||||||
|
try:
|
||||||
|
r = self._session.get(prefix_url("/login/"), allow_redirects=False)
|
||||||
|
except requests.RequestException as e:
|
||||||
|
_exception.handle_requests_error(e)
|
||||||
|
_exception.handle_http_error(r.status_code)
|
||||||
|
return "https://www.messenger.com/" == r.headers.get("Location")
|
||||||
|
|
||||||
|
def logout(self) -> None:
|
||||||
|
"""Safely log out the user.
|
||||||
|
|
||||||
|
The session object must not be used after this action has been performed!
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> session.logout()
|
||||||
|
"""
|
||||||
|
data = {"fb_dtsg": self._fb_dtsg}
|
||||||
|
try:
|
||||||
|
r = self._session.post(
|
||||||
|
prefix_url("/logout/"), data=data, allow_redirects=False
|
||||||
|
)
|
||||||
|
except requests.RequestException as e:
|
||||||
|
_exception.handle_requests_error(e)
|
||||||
|
_exception.handle_http_error(r.status_code)
|
||||||
|
|
||||||
|
if "Location" not in r.headers:
|
||||||
|
raise _exception.FacebookError("Failed logging out, was not redirected!")
|
||||||
|
if "https://www.messenger.com/login/" != r.headers["Location"]:
|
||||||
|
raise _exception.FacebookError(
|
||||||
|
"Failed logging out, got bad redirect: {}".format(r.headers["Location"])
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _from_session(cls, session):
|
||||||
|
# TODO: Automatically set user_id when the cookie changes in the session
|
||||||
|
user_id = get_user_id(session)
|
||||||
|
|
||||||
|
# Make a request to the main page to retrieve ServerJSDefine entries
|
||||||
|
try:
|
||||||
|
r = session.get(prefix_url("/"), allow_redirects=True)
|
||||||
|
except requests.RequestException as e:
|
||||||
|
_exception.handle_requests_error(e)
|
||||||
|
_exception.handle_http_error(r.status_code)
|
||||||
|
|
||||||
|
define = parse_server_js_define(r.content.decode("utf-8"))
|
||||||
|
|
||||||
|
fb_dtsg = get_fb_dtsg(define)
|
||||||
|
if fb_dtsg is None:
|
||||||
|
raise _exception.ParseError("Could not find fb_dtsg", data=define)
|
||||||
|
if not fb_dtsg:
|
||||||
|
# Happens when the client is not actually logged in
|
||||||
|
raise _exception.NotLoggedIn(
|
||||||
|
"Found empty fb_dtsg, the session was probably invalid."
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
revision = int(define["SiteData"]["client_revision"])
|
||||||
|
except TypeError:
|
||||||
|
raise _exception.ParseError("Could not find client revision", data=define)
|
||||||
|
|
||||||
|
return cls(user_id=user_id, fb_dtsg=fb_dtsg, revision=revision, session=session)
|
||||||
|
|
||||||
|
def get_cookies(self) -> Mapping[str, str]:
|
||||||
|
"""Retrieve session cookies, that can later be used in `from_cookies`.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A dictionary containing session cookies
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> cookies = session.get_cookies()
|
||||||
|
"""
|
||||||
|
return self._session.cookies.get_dict()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_cookies(cls, cookies: Mapping[str, str]):
|
||||||
|
"""Load a session from session cookies.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cookies: A dictionary containing session cookies
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> cookies = session.get_cookies()
|
||||||
|
>>> # Store cookies somewhere, and then subsequently
|
||||||
|
>>> session = fbchat.Session.from_cookies(cookies)
|
||||||
|
"""
|
||||||
|
session = session_factory()
|
||||||
|
session.cookies = requests.cookies.merge_cookies(session.cookies, cookies)
|
||||||
|
return cls._from_session(session=session)
|
||||||
|
|
||||||
|
def _post(self, url, data, files=None, as_graphql=False):
|
||||||
|
data.update(self._get_params())
|
||||||
|
try:
|
||||||
|
r = self._session.post(prefix_url(url), data=data, files=files)
|
||||||
|
except requests.RequestException as e:
|
||||||
|
_exception.handle_requests_error(e)
|
||||||
|
# Facebook's encoding is always UTF-8
|
||||||
|
r.encoding = "utf-8"
|
||||||
|
_exception.handle_http_error(r.status_code)
|
||||||
|
if r.text is None or len(r.text) == 0:
|
||||||
|
raise _exception.HTTPError("Error when sending request: Got empty response")
|
||||||
|
if as_graphql:
|
||||||
|
return _graphql.response_to_json(r.text)
|
||||||
|
else:
|
||||||
|
text = _util.strip_json_cruft(r.text)
|
||||||
|
j = _util.parse_json(text)
|
||||||
|
log.debug(j)
|
||||||
|
return j
|
||||||
|
|
||||||
|
def _payload_post(self, url, data, files=None):
|
||||||
|
j = self._post(url, data, files=files)
|
||||||
|
_exception.handle_payload_error(j)
|
||||||
|
|
||||||
|
# update fb_dtsg token if received in response
|
||||||
|
if "jsmods" in j:
|
||||||
|
define = _util.get_jsmods_define(j["jsmods"]["define"])
|
||||||
|
fb_dtsg = get_fb_dtsg(define)
|
||||||
|
if fb_dtsg:
|
||||||
|
self._fb_dtsg = fb_dtsg
|
||||||
|
|
||||||
|
try:
|
||||||
|
return j["payload"]
|
||||||
|
except (KeyError, TypeError) as e:
|
||||||
|
raise _exception.ParseError("Missing payload", data=j) from e
|
||||||
|
|
||||||
|
def _graphql_requests(self, *queries):
|
||||||
|
# TODO: Explain usage of GraphQL, probably in the docs
|
||||||
|
# Perhaps provide this API as public?
|
||||||
|
data = {
|
||||||
|
"method": "GET",
|
||||||
|
"response_format": "json",
|
||||||
|
"queries": _graphql.queries_to_json(*queries),
|
||||||
|
}
|
||||||
|
return self._post("/api/graphqlbatch/", data, as_graphql=True)
|
||||||
|
|
||||||
|
def _do_send_request(self, data):
|
||||||
|
now = _util.now()
|
||||||
|
offline_threading_id = _util.generate_offline_threading_id()
|
||||||
|
data["client"] = "mercury"
|
||||||
|
data["author"] = "fbid:{}".format(self._user_id)
|
||||||
|
data["timestamp"] = _util.datetime_to_millis(now)
|
||||||
|
data["source"] = "source:chat:web"
|
||||||
|
data["offline_threading_id"] = offline_threading_id
|
||||||
|
data["message_id"] = offline_threading_id
|
||||||
|
data["threading_id"] = generate_message_id(now, self._client_id)
|
||||||
|
data["ephemeral_ttl_mode:"] = "0"
|
||||||
|
j = self._post("/messaging/send/", data)
|
||||||
|
|
||||||
|
_exception.handle_payload_error(j)
|
||||||
|
|
||||||
|
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.ParseError("No message IDs could be found", data=j) from e
|
||||||
|
|
||||||
|
def _uri_share_data(self, data):
|
||||||
|
data["image_height"] = 960
|
||||||
|
data["image_width"] = 960
|
||||||
|
data["__user"] = self.user.id
|
||||||
|
j = self._post("/message_share_attachment/fromURI/", data)
|
||||||
|
return j["payload"]["share_data"]
|
||||||
|
|
||||||
|
def to_file(self, filename):
|
||||||
|
"""Save the session to a file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filename: The file to save the session to
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> session = fbchat.Session.from_cookies(cookies)
|
||||||
|
>>> session.to_file("session.json")
|
||||||
|
"""
|
||||||
|
with open(filename, "w") as f:
|
||||||
|
json.dump(self.get_cookies(), f)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_file(cls, filename):
|
||||||
|
"""Load a session from a file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filename: The file to load the session from
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> session = fbchat.Session.from_file("session.json")
|
||||||
|
"""
|
||||||
|
with open(filename, "r") as f:
|
||||||
|
cookies = json.load(f)
|
||||||
|
return cls.from_cookies(cookies)
|
||||||
187
fbchat/_state.py
187
fbchat/_state.py
@@ -1,187 +0,0 @@
|
|||||||
# -*- coding: UTF-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
import attr
|
|
||||||
import bs4
|
|
||||||
import re
|
|
||||||
import requests
|
|
||||||
import random
|
|
||||||
|
|
||||||
from . import _util, _exception
|
|
||||||
|
|
||||||
FB_DTSG_REGEX = re.compile(r'name="fb_dtsg" value="(.*?)"')
|
|
||||||
|
|
||||||
|
|
||||||
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 _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 "home" in 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 "home" in 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 "home" in 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 "home" in 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."""
|
|
||||||
|
|
||||||
fb_dtsg = attr.ib()
|
|
||||||
_revision = attr.ib()
|
|
||||||
_session = attr.ib(factory=session_factory)
|
|
||||||
_counter = attr.ib(0)
|
|
||||||
_logout_h = attr.ib(None)
|
|
||||||
|
|
||||||
def get_user_id(self):
|
|
||||||
rtn = self.get_cookies().get("c_user")
|
|
||||||
if rtn is None:
|
|
||||||
return None
|
|
||||||
return str(rtn)
|
|
||||||
|
|
||||||
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, 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 "home" in 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 "home" in 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):
|
|
||||||
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(
|
|
||||||
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)
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
# -*- coding: UTF-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
import attr
|
|
||||||
from ._attachment import Attachment
|
|
||||||
|
|
||||||
|
|
||||||
@attr.s(cmp=False, init=False)
|
|
||||||
class Sticker(Attachment):
|
|
||||||
"""Represents a Facebook sticker that has been sent to a thread as an attachment"""
|
|
||||||
|
|
||||||
#: The sticker-pack's ID
|
|
||||||
pack = attr.ib(None)
|
|
||||||
#: Whether the sticker is animated
|
|
||||||
is_animated = attr.ib(False)
|
|
||||||
|
|
||||||
# If the sticker is animated, the following should be present
|
|
||||||
#: URL to a medium spritemap
|
|
||||||
medium_sprite_image = attr.ib(None)
|
|
||||||
#: URL to a large spritemap
|
|
||||||
large_sprite_image = attr.ib(None)
|
|
||||||
#: The amount of frames present in the spritemap pr. row
|
|
||||||
frames_per_row = attr.ib(None)
|
|
||||||
#: The amount of frames present in the spritemap pr. coloumn
|
|
||||||
frames_per_col = attr.ib(None)
|
|
||||||
#: The frame rate the spritemap is intended to be played in
|
|
||||||
frame_rate = attr.ib(None)
|
|
||||||
|
|
||||||
#: URL to the sticker's image
|
|
||||||
url = attr.ib(None)
|
|
||||||
#: Width of the sticker
|
|
||||||
width = attr.ib(None)
|
|
||||||
#: Height of the sticker
|
|
||||||
height = attr.ib(None)
|
|
||||||
#: The sticker's label/name
|
|
||||||
label = attr.ib(None)
|
|
||||||
|
|
||||||
def __init__(self, uid=None):
|
|
||||||
super(Sticker, self).__init__(uid=uid)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _from_graphql(cls, data):
|
|
||||||
if not data:
|
|
||||||
return None
|
|
||||||
self = cls(uid=data["id"])
|
|
||||||
if data.get("pack"):
|
|
||||||
self.pack = data["pack"].get("id")
|
|
||||||
if data.get("sprite_image"):
|
|
||||||
self.is_animated = True
|
|
||||||
self.medium_sprite_image = data["sprite_image"].get("uri")
|
|
||||||
self.large_sprite_image = data["sprite_image_2x"].get("uri")
|
|
||||||
self.frames_per_row = data.get("frames_per_row")
|
|
||||||
self.frames_per_col = data.get("frames_per_column")
|
|
||||||
self.frame_rate = data.get("frame_rate")
|
|
||||||
self.url = data.get("url")
|
|
||||||
self.width = data.get("width")
|
|
||||||
self.height = data.get("height")
|
|
||||||
if data.get("label"):
|
|
||||||
self.label = data["label"]
|
|
||||||
return self
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
# -*- coding: UTF-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
import attr
|
|
||||||
from ._core import Enum
|
|
||||||
|
|
||||||
|
|
||||||
class ThreadType(Enum):
|
|
||||||
"""Used to specify what type of Facebook thread is being used. See :ref:`intro_threads` for more info"""
|
|
||||||
|
|
||||||
USER = 1
|
|
||||||
GROUP = 2
|
|
||||||
ROOM = 2
|
|
||||||
PAGE = 3
|
|
||||||
|
|
||||||
|
|
||||||
class ThreadLocation(Enum):
|
|
||||||
"""Used to specify where a thread is located (inbox, pending, archived, other)."""
|
|
||||||
|
|
||||||
INBOX = "INBOX"
|
|
||||||
PENDING = "PENDING"
|
|
||||||
ARCHIVED = "ARCHIVED"
|
|
||||||
OTHER = "OTHER"
|
|
||||||
|
|
||||||
|
|
||||||
class ThreadColor(Enum):
|
|
||||||
"""Used to specify a thread colors"""
|
|
||||||
|
|
||||||
MESSENGER_BLUE = "#0084ff"
|
|
||||||
VIKING = "#44bec7"
|
|
||||||
GOLDEN_POPPY = "#ffc300"
|
|
||||||
RADICAL_RED = "#fa3c4c"
|
|
||||||
SHOCKING = "#d696bb"
|
|
||||||
PICTON_BLUE = "#6699cc"
|
|
||||||
FREE_SPEECH_GREEN = "#13cf13"
|
|
||||||
PUMPKIN = "#ff7e29"
|
|
||||||
LIGHT_CORAL = "#e68585"
|
|
||||||
MEDIUM_SLATE_BLUE = "#7646ff"
|
|
||||||
DEEP_SKY_BLUE = "#20cef5"
|
|
||||||
FERN = "#67b868"
|
|
||||||
CAMEO = "#d4a88c"
|
|
||||||
BRILLIANT_ROSE = "#ff5ca1"
|
|
||||||
BILOBA_FLOWER = "#a695c7"
|
|
||||||
TICKLE_ME_PINK = "#ff7ca8"
|
|
||||||
MALACHITE = "#1adb5b"
|
|
||||||
RUBY = "#f01d6a"
|
|
||||||
DARK_TANGERINE = "#ff9c19"
|
|
||||||
BRIGHT_TURQUOISE = "#0edcde"
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _from_graphql(cls, color):
|
|
||||||
if color is None:
|
|
||||||
return None
|
|
||||||
if not color:
|
|
||||||
return cls.MESSENGER_BLUE
|
|
||||||
color = color[2:] # Strip the alpha value
|
|
||||||
value = "#{}".format(color.lower())
|
|
||||||
return cls._extend_if_invalid(value)
|
|
||||||
|
|
||||||
|
|
||||||
@attr.s(cmp=False, init=False)
|
|
||||||
class Thread(object):
|
|
||||||
"""Represents a Facebook thread"""
|
|
||||||
|
|
||||||
#: The unique identifier of the thread. Can be used a ``thread_id``. See :ref:`intro_threads` for more info
|
|
||||||
uid = attr.ib(converter=str)
|
|
||||||
#: Specifies the type of thread. Can be used a ``thread_type``. See :ref:`intro_threads` for more info
|
|
||||||
type = attr.ib()
|
|
||||||
#: A url to the thread's picture
|
|
||||||
photo = attr.ib(None)
|
|
||||||
#: The name of the thread
|
|
||||||
name = attr.ib(None)
|
|
||||||
#: Timestamp of last message
|
|
||||||
last_message_timestamp = attr.ib(None)
|
|
||||||
#: Number of messages in the thread
|
|
||||||
message_count = attr.ib(None)
|
|
||||||
#: Set :class:`Plan`
|
|
||||||
plan = attr.ib(None)
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
_type,
|
|
||||||
uid,
|
|
||||||
photo=None,
|
|
||||||
name=None,
|
|
||||||
last_message_timestamp=None,
|
|
||||||
message_count=None,
|
|
||||||
plan=None,
|
|
||||||
):
|
|
||||||
self.uid = str(uid)
|
|
||||||
self.type = _type
|
|
||||||
self.photo = photo
|
|
||||||
self.name = name
|
|
||||||
self.last_message_timestamp = last_message_timestamp
|
|
||||||
self.message_count = message_count
|
|
||||||
self.plan = plan
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _parse_customization_info(data):
|
|
||||||
if data is None or data.get("customization_info") is None:
|
|
||||||
return {}
|
|
||||||
info = data["customization_info"]
|
|
||||||
|
|
||||||
rtn = {
|
|
||||||
"emoji": info.get("emoji"),
|
|
||||||
"color": ThreadColor._from_graphql(info.get("outgoing_bubble_color")),
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
data.get("thread_type") == "GROUP"
|
|
||||||
or data.get("is_group_thread")
|
|
||||||
or data.get("thread_key", {}).get("thread_fbid")
|
|
||||||
):
|
|
||||||
rtn["nicknames"] = {}
|
|
||||||
for k in info.get("participant_customizations", []):
|
|
||||||
rtn["nicknames"][k["participant_id"]] = k.get("nickname")
|
|
||||||
elif info.get("participant_customizations"):
|
|
||||||
uid = data.get("thread_key", {}).get("other_user_id") or data.get("id")
|
|
||||||
pc = info["participant_customizations"]
|
|
||||||
if len(pc) > 0:
|
|
||||||
if pc[0].get("participant_id") == uid:
|
|
||||||
rtn["nickname"] = pc[0].get("nickname")
|
|
||||||
else:
|
|
||||||
rtn["own_nickname"] = pc[0].get("nickname")
|
|
||||||
if len(pc) > 1:
|
|
||||||
if pc[1].get("participant_id") == uid:
|
|
||||||
rtn["nickname"] = pc[1].get("nickname")
|
|
||||||
else:
|
|
||||||
rtn["own_nickname"] = pc[1].get("nickname")
|
|
||||||
return rtn
|
|
||||||
4
fbchat/_threads/__init__.py
Normal file
4
fbchat/_threads/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
from ._abc import *
|
||||||
|
from ._group import *
|
||||||
|
from ._user import *
|
||||||
|
from ._page import *
|
||||||
873
fbchat/_threads/_abc.py
Normal file
873
fbchat/_threads/_abc.py
Normal file
@@ -0,0 +1,873 @@
|
|||||||
|
import abc
|
||||||
|
import attr
|
||||||
|
import collections
|
||||||
|
import datetime
|
||||||
|
from .._common import log, attrs_default
|
||||||
|
from .. import _util, _exception, _session, _graphql, _models
|
||||||
|
from typing import MutableMapping, Mapping, Any, Iterable, Tuple, Optional
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_COLOR = "#0084ff"
|
||||||
|
SETABLE_COLORS = (
|
||||||
|
DEFAULT_COLOR,
|
||||||
|
"#44bec7",
|
||||||
|
"#ffc300",
|
||||||
|
"#fa3c4c",
|
||||||
|
"#d696bb",
|
||||||
|
"#6699cc",
|
||||||
|
"#13cf13",
|
||||||
|
"#ff7e29",
|
||||||
|
"#e68585",
|
||||||
|
"#7646ff",
|
||||||
|
"#20cef5",
|
||||||
|
"#67b868",
|
||||||
|
"#d4a88c",
|
||||||
|
"#ff5ca1",
|
||||||
|
"#a695c7",
|
||||||
|
"#ff7ca8",
|
||||||
|
"#1adb5b",
|
||||||
|
"#f01d6a",
|
||||||
|
"#ff9c19",
|
||||||
|
"#0edcde",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ThreadABC(metaclass=abc.ABCMeta):
|
||||||
|
"""Implemented by thread-like classes.
|
||||||
|
|
||||||
|
This is private to implement.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
@abc.abstractmethod
|
||||||
|
def session(self) -> _session.Session:
|
||||||
|
"""The session to use when making requests."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@property
|
||||||
|
@abc.abstractmethod
|
||||||
|
def id(self) -> str:
|
||||||
|
"""The unique identifier of the thread."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def _to_send_data(self) -> MutableMapping[str, str]:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
# Note:
|
||||||
|
# You can go out of Facebook's spec with `self.session._do_send_request`!
|
||||||
|
#
|
||||||
|
# A few examples:
|
||||||
|
# - You can send a sticker and an emoji at the same time
|
||||||
|
# - You can wave, send a sticker and text at the same time
|
||||||
|
# - You can reply to a message with a sticker
|
||||||
|
#
|
||||||
|
# We won't support those use cases, it'll make for a confusing API!
|
||||||
|
# If we absolutely need to in the future, we can always add extra functionality
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def _copy(self) -> "ThreadABC":
|
||||||
|
"""It may or may not be a good idea to attach the current thread to new objects.
|
||||||
|
|
||||||
|
So for now, we use this method to create a new thread.
|
||||||
|
|
||||||
|
This should return the minimal representation of the thread (e.g. not UserData).
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def fetch(self):
|
||||||
|
# TODO: This
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def wave(self, first: bool = True) -> str:
|
||||||
|
"""Wave hello to the thread.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
first: Whether to wave first or wave back
|
||||||
|
|
||||||
|
Example:
|
||||||
|
Wave back to the thread.
|
||||||
|
|
||||||
|
>>> thread.wave(False)
|
||||||
|
"""
|
||||||
|
data = self._to_send_data()
|
||||||
|
data["action_type"] = "ma-type:user-generated-message"
|
||||||
|
data["lightweight_action_attachment[lwa_state]"] = (
|
||||||
|
"INITIATED" if first else "RECIPROCATED"
|
||||||
|
)
|
||||||
|
data["lightweight_action_attachment[lwa_type]"] = "WAVE"
|
||||||
|
message_id, thread_id = self.session._do_send_request(data)
|
||||||
|
return message_id
|
||||||
|
|
||||||
|
def send_text(
|
||||||
|
self,
|
||||||
|
text: str,
|
||||||
|
mentions: Iterable["_models.Mention"] = None,
|
||||||
|
files: Iterable[Tuple[str, str]] = None,
|
||||||
|
reply_to_id: str = None,
|
||||||
|
uri: str = None
|
||||||
|
) -> str:
|
||||||
|
"""Send a message to the thread.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Text to send
|
||||||
|
mentions: Optional mentions
|
||||||
|
files: Optional tuples, each containing an uploaded file's ID and mimetype.
|
||||||
|
See `ThreadABC.send_files` for an example.
|
||||||
|
reply_to_id: Optional message to reply to
|
||||||
|
uri: Uri to formulate a sharable attachment with
|
||||||
|
|
||||||
|
Example:
|
||||||
|
Send a message with a mention to a thread.
|
||||||
|
|
||||||
|
>>> mention = fbchat.Mention(thread_id="1234", offset=5, length=2)
|
||||||
|
>>> message_id = thread.send_text("A message", mentions=[mention])
|
||||||
|
|
||||||
|
Reply to the message.
|
||||||
|
|
||||||
|
>>> thread.send_text("A reply", reply_to_id=message_id)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The sent message
|
||||||
|
"""
|
||||||
|
data = self._to_send_data()
|
||||||
|
data["action_type"] = "ma-type:user-generated-message"
|
||||||
|
if text is not None: # To support `send_files`
|
||||||
|
data["body"] = text
|
||||||
|
|
||||||
|
for i, mention in enumerate(mentions or ()):
|
||||||
|
data.update(mention._to_send_data(i))
|
||||||
|
|
||||||
|
if files:
|
||||||
|
data["has_attachment"] = True
|
||||||
|
|
||||||
|
if uri:
|
||||||
|
data.update(self._generate_shareable_attachment(uri))
|
||||||
|
|
||||||
|
for i, (file_id, mimetype) in enumerate(files or ()):
|
||||||
|
data["{}s[{}]".format(_util.mimetype_to_key(mimetype), i)] = file_id
|
||||||
|
|
||||||
|
if reply_to_id:
|
||||||
|
data["replied_to_message_id"] = reply_to_id
|
||||||
|
|
||||||
|
return self.session._do_send_request(data)
|
||||||
|
|
||||||
|
def send_emoji(self, emoji: str, size: "_models.EmojiSize") -> str:
|
||||||
|
"""Send an emoji to the thread.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
emoji: The emoji to send
|
||||||
|
size: The size of the emoji
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> thread.send_emoji("😀", size=fbchat.EmojiSize.LARGE)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The sent message
|
||||||
|
"""
|
||||||
|
data = self._to_send_data()
|
||||||
|
data["action_type"] = "ma-type:user-generated-message"
|
||||||
|
data["body"] = emoji
|
||||||
|
data["tags[0]"] = "hot_emoji_size:{}".format(size.name.lower())
|
||||||
|
return self.session._do_send_request(data)
|
||||||
|
|
||||||
|
def send_sticker(self, sticker_id: str) -> str:
|
||||||
|
"""Send a sticker to the thread.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sticker_id: ID of the sticker to send
|
||||||
|
|
||||||
|
Example:
|
||||||
|
Send a sticker with the id "1889713947839631"
|
||||||
|
|
||||||
|
>>> thread.send_sticker("1889713947839631")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The sent message
|
||||||
|
"""
|
||||||
|
data = self._to_send_data()
|
||||||
|
data["action_type"] = "ma-type:user-generated-message"
|
||||||
|
data["sticker_id"] = sticker_id
|
||||||
|
return self.session._do_send_request(data)
|
||||||
|
|
||||||
|
def _send_location(self, current, latitude, longitude):
|
||||||
|
data = self._to_send_data()
|
||||||
|
data["action_type"] = "ma-type:user-generated-message"
|
||||||
|
data["location_attachment[coordinates][latitude]"] = latitude
|
||||||
|
data["location_attachment[coordinates][longitude]"] = longitude
|
||||||
|
data["location_attachment[is_current_location]"] = current
|
||||||
|
return self.session._do_send_request(data)
|
||||||
|
|
||||||
|
def send_location(self, latitude: float, longitude: float):
|
||||||
|
"""Send a given location to a thread as the user's current location.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
latitude: The location latitude
|
||||||
|
longitude: The location longitude
|
||||||
|
|
||||||
|
Example:
|
||||||
|
Send a location in London, United Kingdom.
|
||||||
|
|
||||||
|
>>> thread.send_location(51.5287718, -0.2416815)
|
||||||
|
"""
|
||||||
|
self._send_location(True, latitude=latitude, longitude=longitude)
|
||||||
|
|
||||||
|
def send_pinned_location(self, latitude: float, longitude: float):
|
||||||
|
"""Send a given location to a thread as a pinned location.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
latitude: The location latitude
|
||||||
|
longitude: The location longitude
|
||||||
|
|
||||||
|
Example:
|
||||||
|
Send a pinned location in Beijing, China.
|
||||||
|
|
||||||
|
>>> thread.send_pinned_location(39.9390731, 116.117273)
|
||||||
|
"""
|
||||||
|
self._send_location(False, latitude=latitude, longitude=longitude)
|
||||||
|
|
||||||
|
def send_files(self, files: Iterable[Tuple[str, str]]):
|
||||||
|
"""Send files from file IDs to a thread.
|
||||||
|
|
||||||
|
`files` should be a list of tuples, with a file's ID and mimetype.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
Upload and send a video to a thread.
|
||||||
|
|
||||||
|
>>> with open("video.mp4", "rb") as f:
|
||||||
|
... files = client.upload([("video.mp4", f, "video/mp4")])
|
||||||
|
>>>
|
||||||
|
>>> thread.send_files(files)
|
||||||
|
"""
|
||||||
|
return self.send_text(text=None, files=files)
|
||||||
|
|
||||||
|
def send_uri(self, uri: str, **kwargs):
|
||||||
|
"""Send a uri preview to a thread.
|
||||||
|
Args:
|
||||||
|
uri: uri to preview
|
||||||
|
"""
|
||||||
|
if kwargs.get('text') is None:
|
||||||
|
kwargs['text'] = None
|
||||||
|
self.send_text(uri=uri, **kwargs)
|
||||||
|
|
||||||
|
def _generate_shareable_attachment(self, uri):
|
||||||
|
"""Send a uri preview to a thread.
|
||||||
|
Args:
|
||||||
|
uri: uri to preview
|
||||||
|
Returns:
|
||||||
|
:ref:`Message ID <intro_message_ids>` of the sent message
|
||||||
|
Raises:
|
||||||
|
FBchatException: If request failed
|
||||||
|
"""
|
||||||
|
url_data = self.session._uri_share_data({"uri": uri})
|
||||||
|
data = self._to_send_data()
|
||||||
|
data["action_type"] = "ma-type:user-generated-message"
|
||||||
|
data["shareable_attachment[share_type]"] = url_data["share_type"]
|
||||||
|
|
||||||
|
# Most uri params will come back as dict
|
||||||
|
if isinstance(url_data["share_params"], dict):
|
||||||
|
data["has_attachment"] = True
|
||||||
|
for key in url_data["share_params"]:
|
||||||
|
if isinstance(url_data["share_params"][key], dict):
|
||||||
|
for key2 in url_data["share_params"][key]:
|
||||||
|
data[
|
||||||
|
"shareable_attachment[share_params][{}][{}]".format(
|
||||||
|
key, key2
|
||||||
|
)
|
||||||
|
] = url_data["share_params"][key][key2]
|
||||||
|
else:
|
||||||
|
data[
|
||||||
|
"shareable_attachment[share_params][{}]".format(key)
|
||||||
|
] = url_data["share_params"][key]
|
||||||
|
|
||||||
|
# Some (such as facebook profile pages) will just be a list
|
||||||
|
else:
|
||||||
|
data["has_attachment"] = False
|
||||||
|
for index, val in enumerate(url_data["share_params"]):
|
||||||
|
data["shareable_attachment[share_params][{}]".format(index)] = val
|
||||||
|
return data
|
||||||
|
|
||||||
|
# xmd = {"quick_replies": []}
|
||||||
|
# for quick_reply in 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(quick_replies) == 1 and quick_replies[0].is_response:
|
||||||
|
# xmd["quick_replies"] = xmd["quick_replies"][0]
|
||||||
|
# data["platform_xmd"] = _util.json_minimal(xmd)
|
||||||
|
|
||||||
|
# TODO: This!
|
||||||
|
# def quick_reply(self, quick_reply: QuickReply, payload=None):
|
||||||
|
# """Reply to chosen quick reply.
|
||||||
|
#
|
||||||
|
# Args:
|
||||||
|
# quick_reply: Quick reply to reply to
|
||||||
|
# payload: Optional answer to the quick reply
|
||||||
|
# """
|
||||||
|
# if isinstance(quick_reply, QuickReplyText):
|
||||||
|
# new = QuickReplyText(
|
||||||
|
# payload=quick_reply.payload,
|
||||||
|
# external_payload=quick_reply.external_payload,
|
||||||
|
# data=quick_reply.data,
|
||||||
|
# is_response=True,
|
||||||
|
# title=quick_reply.title,
|
||||||
|
# image_url=quick_reply.image_url,
|
||||||
|
# )
|
||||||
|
# return self.send(Message(text=quick_reply.title, quick_replies=[new]))
|
||||||
|
# elif isinstance(quick_reply, QuickReplyLocation):
|
||||||
|
# if not isinstance(payload, LocationAttachment):
|
||||||
|
# raise TypeError("Payload must be an instance of `LocationAttachment`")
|
||||||
|
# return self.send_location(payload)
|
||||||
|
# elif isinstance(quick_reply, QuickReplyEmail):
|
||||||
|
# new = QuickReplyEmail(
|
||||||
|
# payload=payload if payload else self.get_emails()[0],
|
||||||
|
# external_payload=quick_reply.payload,
|
||||||
|
# data=quick_reply.data,
|
||||||
|
# is_response=True,
|
||||||
|
# image_url=quick_reply.image_url,
|
||||||
|
# )
|
||||||
|
# return self.send(Message(text=payload, quick_replies=[new]))
|
||||||
|
# elif isinstance(quick_reply, QuickReplyPhoneNumber):
|
||||||
|
# new = QuickReplyPhoneNumber(
|
||||||
|
# payload=payload if payload else self.get_phone_numbers()[0],
|
||||||
|
# external_payload=quick_reply.payload,
|
||||||
|
# data=quick_reply.data,
|
||||||
|
# is_response=True,
|
||||||
|
# image_url=quick_reply.image_url,
|
||||||
|
# )
|
||||||
|
# return self.send(Message(text=payload, quick_replies=[new]))
|
||||||
|
|
||||||
|
def _search_messages(self, query, offset, limit):
|
||||||
|
data = {
|
||||||
|
"query": query,
|
||||||
|
"snippetOffset": offset,
|
||||||
|
"snippetLimit": limit,
|
||||||
|
"identifier": "thread_fbid",
|
||||||
|
"thread_fbid": self.id,
|
||||||
|
}
|
||||||
|
j = self.session._payload_post("/ajax/mercury/search_snippets.php?dpr=1", data)
|
||||||
|
|
||||||
|
result = j["search_snippets"][query].get(self.id)
|
||||||
|
if not result:
|
||||||
|
return (0, [])
|
||||||
|
|
||||||
|
thread = self._copy()
|
||||||
|
snippets = [
|
||||||
|
_models.MessageSnippet._parse(thread, snippet)
|
||||||
|
for snippet in result["snippets"]
|
||||||
|
]
|
||||||
|
return (result["num_total_snippets"], snippets)
|
||||||
|
|
||||||
|
def search_messages(
|
||||||
|
self, query: str, limit: int
|
||||||
|
) -> Iterable[_models.MessageSnippet]:
|
||||||
|
"""Find and get message IDs by query.
|
||||||
|
|
||||||
|
Warning! If someone send a message to the thread that matches the query, while
|
||||||
|
we're searching, some snippets will get returned twice.
|
||||||
|
|
||||||
|
This is fundamentally not fixable, it's just how the endpoint is implemented.
|
||||||
|
|
||||||
|
The returned message snippets are ordered by last sent first.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Text to search for
|
||||||
|
limit: Max. number of message snippets to retrieve
|
||||||
|
|
||||||
|
Example:
|
||||||
|
Fetch the latest message in the thread that matches the query.
|
||||||
|
|
||||||
|
>>> (message,) = thread.search_messages("abc", limit=1)
|
||||||
|
>>> message.text
|
||||||
|
"Some text and abc"
|
||||||
|
"""
|
||||||
|
offset = 0
|
||||||
|
# The max limit is measured empirically to 420, safe default chosen below
|
||||||
|
for limit in _util.get_limits(limit, max_limit=50):
|
||||||
|
_, snippets = self._search_messages(query, offset, limit)
|
||||||
|
yield from snippets
|
||||||
|
if len(snippets) < limit:
|
||||||
|
return # No more data to fetch
|
||||||
|
offset += limit
|
||||||
|
|
||||||
|
def _fetch_messages(self, limit, before):
|
||||||
|
params = {
|
||||||
|
"id": self.id,
|
||||||
|
"message_limit": limit,
|
||||||
|
"load_messages": True,
|
||||||
|
"load_read_receipts": True,
|
||||||
|
# "load_delivery_receipts": False,
|
||||||
|
# "is_work_teamwork_not_putting_muted_in_unreads": False,
|
||||||
|
"before": _util.datetime_to_millis(before) if before else None,
|
||||||
|
}
|
||||||
|
(j,) = self.session._graphql_requests(
|
||||||
|
_graphql.from_doc_id("1860982147341344", params) # 2696825200377124
|
||||||
|
)
|
||||||
|
|
||||||
|
if j.get("message_thread") is None:
|
||||||
|
raise _exception.ParseError("Could not fetch messages", data=j)
|
||||||
|
|
||||||
|
# TODO: Should we parse the returned thread data, too?
|
||||||
|
|
||||||
|
read_receipts = j["message_thread"]["read_receipts"]["nodes"]
|
||||||
|
|
||||||
|
thread = self._copy()
|
||||||
|
return [
|
||||||
|
_models.MessageData._from_graphql(thread, message, read_receipts)
|
||||||
|
for message in j["message_thread"]["messages"]["nodes"]
|
||||||
|
]
|
||||||
|
|
||||||
|
def fetch_messages(self, limit: Optional[int]) -> Iterable["_models.Message"]:
|
||||||
|
"""Fetch messages in a thread.
|
||||||
|
|
||||||
|
The returned messages are ordered by last sent first.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
limit: Max. number of threads to retrieve. If ``None``, all threads will be
|
||||||
|
retrieved.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> for message in thread.fetch_messages(limit=5)
|
||||||
|
... print(message.text)
|
||||||
|
...
|
||||||
|
A message
|
||||||
|
Another message
|
||||||
|
None
|
||||||
|
A fourth message
|
||||||
|
"""
|
||||||
|
# This is measured empirically as 210 in extreme cases, fairly safe default
|
||||||
|
# chosen below
|
||||||
|
MAX_BATCH_LIMIT = 100
|
||||||
|
|
||||||
|
before = None
|
||||||
|
for limit in _util.get_limits(limit, MAX_BATCH_LIMIT):
|
||||||
|
messages = self._fetch_messages(limit, before)
|
||||||
|
messages.reverse()
|
||||||
|
|
||||||
|
if before:
|
||||||
|
# Strip the first messages
|
||||||
|
yield from messages[1:]
|
||||||
|
else:
|
||||||
|
yield from messages
|
||||||
|
|
||||||
|
if len(messages) < MAX_BATCH_LIMIT:
|
||||||
|
return # No more data to fetch
|
||||||
|
|
||||||
|
before = messages[-1].created_at
|
||||||
|
|
||||||
|
def _fetch_images(self, limit, after):
|
||||||
|
data = {"id": self.id, "first": limit, "after": after}
|
||||||
|
(j,) = self.session._graphql_requests(
|
||||||
|
_graphql.from_query_id("515216185516880", data)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not j[self.id]:
|
||||||
|
raise _exception.ParseError("Could not find images", data=j)
|
||||||
|
|
||||||
|
result = j[self.id]["message_shared_media"]
|
||||||
|
|
||||||
|
rtn = []
|
||||||
|
for edge in result["edges"]:
|
||||||
|
node = edge["node"]
|
||||||
|
type_ = node["__typename"]
|
||||||
|
if type_ == "MessageImage":
|
||||||
|
rtn.append(_models.ImageAttachment._from_list(node))
|
||||||
|
elif type_ == "MessageVideo":
|
||||||
|
rtn.append(_models.VideoAttachment._from_list(node))
|
||||||
|
else:
|
||||||
|
log.warning("Unknown image type %s, data: %s", type_, edge)
|
||||||
|
rtn.append(None)
|
||||||
|
|
||||||
|
# result["page_info"]["has_next_page"] is not correct when limit > 12
|
||||||
|
return (result["page_info"]["end_cursor"], rtn)
|
||||||
|
|
||||||
|
def fetch_images(self, limit: Optional[int]) -> Iterable["_models.Attachment"]:
|
||||||
|
"""Fetch images/videos posted in the thread.
|
||||||
|
|
||||||
|
The returned images are ordered by last sent first.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
limit: Max. number of images to retrieve. If ``None``, all images will be
|
||||||
|
retrieved.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> for image in thread.fetch_messages(limit=3)
|
||||||
|
... print(image.id)
|
||||||
|
...
|
||||||
|
1234
|
||||||
|
2345
|
||||||
|
"""
|
||||||
|
cursor = None
|
||||||
|
# The max limit on this request is unknown, so we set it reasonably high
|
||||||
|
# This way `limit=None` also still works
|
||||||
|
for limit in _util.get_limits(limit, max_limit=1000):
|
||||||
|
cursor, images = self._fetch_images(limit, cursor)
|
||||||
|
if not images:
|
||||||
|
return # No more data to fetch
|
||||||
|
for image in images:
|
||||||
|
if image:
|
||||||
|
yield image
|
||||||
|
|
||||||
|
def set_nickname(self, user_id: str, nickname: str):
|
||||||
|
"""Change the nickname of a user in the thread.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User that will have their nickname changed
|
||||||
|
nickname: New nickname
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> thread.set_nickname("1234", "A nickname")
|
||||||
|
"""
|
||||||
|
data = {
|
||||||
|
"nickname": nickname,
|
||||||
|
"participant_id": user_id,
|
||||||
|
"thread_or_other_fbid": self.id,
|
||||||
|
}
|
||||||
|
j = self.session._payload_post(
|
||||||
|
"/messaging/save_thread_nickname/?source=thread_settings&dpr=1", data
|
||||||
|
)
|
||||||
|
|
||||||
|
def set_color(self, color: str):
|
||||||
|
"""Change thread color.
|
||||||
|
|
||||||
|
The new color must be one of the following::
|
||||||
|
|
||||||
|
"#0084ff", "#44bec7", "#ffc300", "#fa3c4c", "#d696bb", "#6699cc",
|
||||||
|
"#13cf13", "#ff7e29", "#e68585", "#7646ff", "#20cef5", "#67b868",
|
||||||
|
"#d4a88c", "#ff5ca1", "#a695c7", "#ff7ca8", "#1adb5b", "#f01d6a",
|
||||||
|
"#ff9c19" or "#0edcde".
|
||||||
|
|
||||||
|
This list is subject to change in the future!
|
||||||
|
|
||||||
|
The default when creating a new thread is ``"#0084ff"``.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
color: New thread color
|
||||||
|
|
||||||
|
Example:
|
||||||
|
Set the thread color to "Coral Pink".
|
||||||
|
|
||||||
|
>>> thread.set_color("#e68585")
|
||||||
|
"""
|
||||||
|
if color not in SETABLE_COLORS:
|
||||||
|
raise ValueError(
|
||||||
|
"Invalid color! Please use one of: {}".format(SETABLE_COLORS)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set color to "" if DEFAULT_COLOR. Just how the endpoint works...
|
||||||
|
if color == DEFAULT_COLOR:
|
||||||
|
color = ""
|
||||||
|
|
||||||
|
data = {"color_choice": color, "thread_or_other_fbid": self.id}
|
||||||
|
j = self.session._payload_post(
|
||||||
|
"/messaging/save_thread_color/?source=thread_settings&dpr=1", data
|
||||||
|
)
|
||||||
|
|
||||||
|
# def set_theme(self, theme_id: str):
|
||||||
|
# data = {
|
||||||
|
# "client_mutation_id": "0",
|
||||||
|
# "actor_id": self.session.user.id,
|
||||||
|
# "thread_id": self.id,
|
||||||
|
# "theme_id": theme_id,
|
||||||
|
# "source": "SETTINGS",
|
||||||
|
# }
|
||||||
|
# j = self.session._graphql_requests(
|
||||||
|
# _graphql.from_doc_id("1768656253222505", {"data": data})
|
||||||
|
# )
|
||||||
|
|
||||||
|
def set_emoji(self, emoji: Optional[str]):
|
||||||
|
"""Change thread emoji.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
emoji: New thread emoji. If ``None``, will be set to the default "LIKE" icon
|
||||||
|
|
||||||
|
Example:
|
||||||
|
Set the thread emoji to "😊".
|
||||||
|
|
||||||
|
>>> thread.set_emoji("😊")
|
||||||
|
"""
|
||||||
|
data = {"emoji_choice": emoji, "thread_or_other_fbid": self.id}
|
||||||
|
# While changing the emoji, the Facebook web client actually sends multiple
|
||||||
|
# different requests, though only this one is required to make the change.
|
||||||
|
j = self.session._payload_post(
|
||||||
|
"/messaging/save_thread_emoji/?source=thread_settings&dpr=1", data
|
||||||
|
)
|
||||||
|
|
||||||
|
def forward_attachment(self, attachment_id: str):
|
||||||
|
"""Forward an attachment.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
attachment_id: Attachment ID to forward
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> thread.forward_attachment("1234")
|
||||||
|
"""
|
||||||
|
data = {
|
||||||
|
"attachment_id": attachment_id,
|
||||||
|
"recipient_map[{}]".format(_util.generate_offline_threading_id()): self.id,
|
||||||
|
}
|
||||||
|
j = self.session._payload_post("/mercury/attachments/forward/", data)
|
||||||
|
if not j.get("success"):
|
||||||
|
raise _exception.ExternalError("Failed forwarding attachment", j["error"])
|
||||||
|
|
||||||
|
def _set_typing(self, typing):
|
||||||
|
data = {
|
||||||
|
"typ": "1" if typing else "0",
|
||||||
|
"thread": self.id,
|
||||||
|
# TODO: This
|
||||||
|
# "to": self.id if isinstance(self, _user.User) else "",
|
||||||
|
"source": "mercury-chat",
|
||||||
|
}
|
||||||
|
j = self.session._payload_post("/ajax/messaging/typ.php", data)
|
||||||
|
|
||||||
|
def start_typing(self):
|
||||||
|
"""Set the current user to start typing in the thread.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> thread.start_typing()
|
||||||
|
"""
|
||||||
|
self._set_typing(True)
|
||||||
|
|
||||||
|
def stop_typing(self):
|
||||||
|
"""Set the current user to stop typing in the thread.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> thread.stop_typing()
|
||||||
|
"""
|
||||||
|
self._set_typing(False)
|
||||||
|
|
||||||
|
def create_plan(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
at: datetime.datetime,
|
||||||
|
location_name: str = None,
|
||||||
|
location_id: str = None,
|
||||||
|
):
|
||||||
|
"""Create a new plan.
|
||||||
|
|
||||||
|
# TODO: Arguments
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Name of the new plan
|
||||||
|
at: When the plan is for
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> thread.create_plan(...)
|
||||||
|
"""
|
||||||
|
return _models.Plan._create(self, name, at, location_name, location_id)
|
||||||
|
|
||||||
|
def create_poll(self, question: str, options: Mapping[str, bool]):
|
||||||
|
"""Create poll in a thread.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
question: The question
|
||||||
|
options: Options and whether you want to select the option
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> thread.create_poll("Test poll", {"Option 1": True, "Option 2": False})
|
||||||
|
"""
|
||||||
|
# We're using ordered dictionaries, because the Facebook endpoint that parses
|
||||||
|
# the POST parameters is badly implemented, and deals with ordering the options
|
||||||
|
# wrongly. If you can find a way to fix this for the endpoint, or if you find
|
||||||
|
# another endpoint, please do suggest it ;)
|
||||||
|
data = collections.OrderedDict(
|
||||||
|
[("question_text", question), ("target_id", self.id)]
|
||||||
|
)
|
||||||
|
|
||||||
|
for i, (text, vote) in enumerate(options.items()):
|
||||||
|
data["option_text_array[{}]".format(i)] = text
|
||||||
|
data["option_is_selected_array[{}]".format(i)] = "1" if vote else "0"
|
||||||
|
|
||||||
|
j = self.session._payload_post(
|
||||||
|
"/messaging/group_polling/create_poll/?dpr=1", data
|
||||||
|
)
|
||||||
|
if j.get("status") != "success":
|
||||||
|
raise _exception.ExternalError(
|
||||||
|
"Failed creating poll: {}".format(j.get("errorTitle")),
|
||||||
|
j.get("errorMessage"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def mute(self, duration: datetime.timedelta = None):
|
||||||
|
"""Mute the thread.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
duration: Time to mute, use ``None`` to mute forever
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> import datetime
|
||||||
|
>>> thread.mute(datetime.timedelta(days=2))
|
||||||
|
"""
|
||||||
|
if duration is None:
|
||||||
|
setting = "-1"
|
||||||
|
else:
|
||||||
|
setting = str(_util.timedelta_to_seconds(duration))
|
||||||
|
data = {"mute_settings": setting, "thread_fbid": self.id}
|
||||||
|
j = self.session._payload_post(
|
||||||
|
"/ajax/mercury/change_mute_thread.php?dpr=1", data
|
||||||
|
)
|
||||||
|
|
||||||
|
def unmute(self):
|
||||||
|
"""Unmute the thread.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> thread.unmute()
|
||||||
|
"""
|
||||||
|
return self.mute(datetime.timedelta(0))
|
||||||
|
|
||||||
|
def _mute_reactions(self, mode: bool):
|
||||||
|
data = {"reactions_mute_mode": "1" if mode else "0", "thread_fbid": self.id}
|
||||||
|
j = self.session._payload_post(
|
||||||
|
"/ajax/mercury/change_reactions_mute_thread/?dpr=1", data
|
||||||
|
)
|
||||||
|
|
||||||
|
def mute_reactions(self):
|
||||||
|
"""Mute thread reactions."""
|
||||||
|
self._mute_reactions(True)
|
||||||
|
|
||||||
|
def unmute_reactions(self):
|
||||||
|
"""Unmute thread reactions."""
|
||||||
|
self._mute_reactions(False)
|
||||||
|
|
||||||
|
def _mute_mentions(self, mode: bool):
|
||||||
|
data = {"mentions_mute_mode": "1" if mode else "0", "thread_fbid": self.id}
|
||||||
|
j = self.session._payload_post(
|
||||||
|
"/ajax/mercury/change_mentions_mute_thread/?dpr=1", data
|
||||||
|
)
|
||||||
|
|
||||||
|
def mute_mentions(self):
|
||||||
|
"""Mute thread mentions."""
|
||||||
|
self._mute_mentions(True)
|
||||||
|
|
||||||
|
def unmute_mentions(self):
|
||||||
|
"""Unmute thread mentions."""
|
||||||
|
self._mute_mentions(False)
|
||||||
|
|
||||||
|
def mark_as_spam(self):
|
||||||
|
"""Mark the thread as spam, and delete it."""
|
||||||
|
data = {"id": self.id}
|
||||||
|
j = self.session._payload_post("/ajax/mercury/mark_spam.php?dpr=1", data)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _delete_many(session, thread_ids):
|
||||||
|
data = {}
|
||||||
|
for i, id_ in enumerate(thread_ids):
|
||||||
|
data["ids[{}]".format(i)] = id_
|
||||||
|
# Not needed any more
|
||||||
|
# j = session._payload_post("/ajax/mercury/change_pinned_status.php?dpr=1", ...)
|
||||||
|
# Both /ajax/mercury/delete_threads.php (with an s) doesn't work
|
||||||
|
j = session._payload_post("/ajax/mercury/delete_thread.php", data)
|
||||||
|
|
||||||
|
def delete(self):
|
||||||
|
"""Delete the thread.
|
||||||
|
|
||||||
|
If you want to delete multiple threads, please use `Client.delete_threads`.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> message.delete()
|
||||||
|
"""
|
||||||
|
self._delete_many(self.session, [self.id])
|
||||||
|
|
||||||
|
def _forced_fetch(self, message_id: str) -> dict:
|
||||||
|
params = {
|
||||||
|
"thread_and_message_id": {"thread_id": self.id, "message_id": message_id}
|
||||||
|
}
|
||||||
|
(j,) = self.session._graphql_requests(
|
||||||
|
_graphql.from_doc_id("1768656253222505", params)
|
||||||
|
)
|
||||||
|
return j
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_color(inp: Optional[str]) -> str:
|
||||||
|
if not inp:
|
||||||
|
return DEFAULT_COLOR
|
||||||
|
# Strip the alpha value, and lower the string
|
||||||
|
return "#{}".format(inp[2:].lower())
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_customization_info(data: Any) -> MutableMapping[str, Any]:
|
||||||
|
if not data or not data.get("customization_info"):
|
||||||
|
return {"emoji": None, "color": DEFAULT_COLOR}
|
||||||
|
info = data["customization_info"]
|
||||||
|
|
||||||
|
rtn = {
|
||||||
|
"emoji": info.get("emoji"),
|
||||||
|
"color": ThreadABC._parse_color(info.get("outgoing_bubble_color")),
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
data.get("thread_type") == "GROUP"
|
||||||
|
or data.get("is_group_thread")
|
||||||
|
or data.get("thread_key", {}).get("thread_fbid")
|
||||||
|
):
|
||||||
|
rtn["nicknames"] = {}
|
||||||
|
for k in info.get("participant_customizations", []):
|
||||||
|
rtn["nicknames"][k["participant_id"]] = k.get("nickname")
|
||||||
|
elif info.get("participant_customizations"):
|
||||||
|
user_id = data.get("thread_key", {}).get("other_user_id") or data.get("id")
|
||||||
|
pc = info["participant_customizations"]
|
||||||
|
if len(pc) > 0:
|
||||||
|
if pc[0].get("participant_id") == user_id:
|
||||||
|
rtn["nickname"] = pc[0].get("nickname")
|
||||||
|
else:
|
||||||
|
rtn["own_nickname"] = pc[0].get("nickname")
|
||||||
|
if len(pc) > 1:
|
||||||
|
if pc[1].get("participant_id") == user_id:
|
||||||
|
rtn["nickname"] = pc[1].get("nickname")
|
||||||
|
else:
|
||||||
|
rtn["own_nickname"] = pc[1].get("nickname")
|
||||||
|
return rtn
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_participants(session, data) -> Iterable["ThreadABC"]:
|
||||||
|
from . import _user, _group, _page
|
||||||
|
|
||||||
|
for node in data["nodes"]:
|
||||||
|
actor = node["messaging_actor"]
|
||||||
|
typename = actor["__typename"]
|
||||||
|
thread_id = actor["id"]
|
||||||
|
if typename == "User":
|
||||||
|
yield _user.User(session=session, id=thread_id)
|
||||||
|
elif typename == "MessageThread":
|
||||||
|
# MessageThread => Group thread
|
||||||
|
yield _group.Group(session=session, id=thread_id)
|
||||||
|
elif typename == "Page":
|
||||||
|
yield _page.Page(session=session, id=thread_id)
|
||||||
|
elif typename == "Group":
|
||||||
|
# We don't handle Facebook "Groups"
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
log.warning("Unknown type %r in %s", typename, data)
|
||||||
|
|
||||||
|
|
||||||
|
@attrs_default
|
||||||
|
class Thread(ThreadABC):
|
||||||
|
"""Represents a Facebook thread, where the actual type is unknown.
|
||||||
|
|
||||||
|
Implements parts of `ThreadABC`, call the method to figure out if your use case is
|
||||||
|
supported. Otherwise, you'll have to use an `User`/`Group`/`Page` object.
|
||||||
|
|
||||||
|
Note: This list may change in minor versions!
|
||||||
|
"""
|
||||||
|
|
||||||
|
#: The session to use when making requests.
|
||||||
|
session = attr.ib(type=_session.Session)
|
||||||
|
#: The unique identifier of the thread.
|
||||||
|
id = attr.ib(converter=str, type=str)
|
||||||
|
|
||||||
|
def _to_send_data(self):
|
||||||
|
raise NotImplementedError(
|
||||||
|
"The method you called is not supported on raw Thread objects."
|
||||||
|
" Please use an appropriate User/Group/Page object instead!"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _copy(self) -> "Thread":
|
||||||
|
return Thread(session=self.session, id=self.id)
|
||||||
279
fbchat/_threads/_group.py
Normal file
279
fbchat/_threads/_group.py
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
import attr
|
||||||
|
import datetime
|
||||||
|
from ._abc import ThreadABC
|
||||||
|
from . import _user
|
||||||
|
from .._common import attrs_default
|
||||||
|
from .. import _util, _session, _graphql, _models
|
||||||
|
|
||||||
|
from typing import Sequence, Iterable, Set, Mapping, Optional
|
||||||
|
|
||||||
|
|
||||||
|
@attrs_default
|
||||||
|
class Group(ThreadABC):
|
||||||
|
"""Represents a Facebook group. Implements `ThreadABC`.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> group = fbchat.Group(session=session, id="1234")
|
||||||
|
"""
|
||||||
|
|
||||||
|
#: The session to use when making requests.
|
||||||
|
session = attr.ib(type=_session.Session)
|
||||||
|
#: The group's unique identifier.
|
||||||
|
id = attr.ib(converter=str, type=str)
|
||||||
|
|
||||||
|
def _to_send_data(self):
|
||||||
|
return {"thread_fbid": self.id}
|
||||||
|
|
||||||
|
def _copy(self) -> "Group":
|
||||||
|
return Group(session=self.session, id=self.id)
|
||||||
|
|
||||||
|
def add_participants(self, user_ids: Iterable[str]):
|
||||||
|
"""Add users to the group.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_ids: One or more user IDs to add
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> group.add_participants(["1234", "2345"])
|
||||||
|
"""
|
||||||
|
data = self._to_send_data()
|
||||||
|
|
||||||
|
data["action_type"] = "ma-type:log-message"
|
||||||
|
data["log_message_type"] = "log:subscribe"
|
||||||
|
|
||||||
|
for i, user_id in enumerate(user_ids):
|
||||||
|
if user_id == self.session.user.id:
|
||||||
|
raise ValueError(
|
||||||
|
"Error when adding users: Cannot add self to group thread"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
data[
|
||||||
|
"log_message_data[added_participants][{}]".format(i)
|
||||||
|
] = "fbid:{}".format(user_id)
|
||||||
|
|
||||||
|
return self.session._do_send_request(data)
|
||||||
|
|
||||||
|
def remove_participant(self, user_id: str):
|
||||||
|
"""Remove user from the group.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User ID to remove
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> group.remove_participant("1234")
|
||||||
|
"""
|
||||||
|
data = {"uid": user_id, "tid": self.id}
|
||||||
|
j = self.session._payload_post("/chat/remove_participants/", data)
|
||||||
|
|
||||||
|
def _admin_status(self, user_ids: Iterable[str], status: bool):
|
||||||
|
data = {"add": status, "thread_fbid": self.id}
|
||||||
|
|
||||||
|
for i, user_id in enumerate(user_ids):
|
||||||
|
data["admin_ids[{}]".format(i)] = str(user_id)
|
||||||
|
|
||||||
|
j = self.session._payload_post("/messaging/save_admins/?dpr=1", data)
|
||||||
|
|
||||||
|
def add_admins(self, user_ids: Iterable[str]):
|
||||||
|
"""Set specified users as group admins.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_ids: One or more user IDs to set admin
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> group.add_admins(["1234", "2345"])
|
||||||
|
"""
|
||||||
|
self._admin_status(user_ids, True)
|
||||||
|
|
||||||
|
def remove_admins(self, user_ids: Iterable[str]):
|
||||||
|
"""Remove admin status from specified users.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_ids: One or more user IDs to remove admin
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> group.remove_admins(["1234", "2345"])
|
||||||
|
"""
|
||||||
|
self._admin_status(user_ids, False)
|
||||||
|
|
||||||
|
def set_title(self, title: str):
|
||||||
|
"""Change title of the group.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
title: New title
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> group.set_title("Abc")
|
||||||
|
"""
|
||||||
|
data = {"thread_name": title, "thread_id": self.id}
|
||||||
|
j = self.session._payload_post("/messaging/set_thread_name/?dpr=1", data)
|
||||||
|
|
||||||
|
def set_image(self, image_id: str):
|
||||||
|
"""Change the group image from an image id.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
image_id: ID of uploaded image
|
||||||
|
|
||||||
|
Example:
|
||||||
|
Upload an image, and use it as the group image.
|
||||||
|
|
||||||
|
>>> with open("image.png", "rb") as f:
|
||||||
|
... (file,) = client.upload([("image.png", f, "image/png")])
|
||||||
|
...
|
||||||
|
>>> group.set_image(file[0])
|
||||||
|
"""
|
||||||
|
data = {"thread_image_id": image_id, "thread_id": self.id}
|
||||||
|
j = self.session._payload_post("/messaging/set_thread_image/?dpr=1", data)
|
||||||
|
|
||||||
|
def set_approval_mode(self, require_admin_approval: bool):
|
||||||
|
"""Change the group's approval mode.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
require_admin_approval: True or False
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> group.set_approval_mode(False)
|
||||||
|
"""
|
||||||
|
data = {"set_mode": int(require_admin_approval), "thread_fbid": self.id}
|
||||||
|
j = self.session._payload_post("/messaging/set_approval_mode/?dpr=1", data)
|
||||||
|
|
||||||
|
def _users_approval(self, user_ids: Iterable[str], approve: bool):
|
||||||
|
data = {
|
||||||
|
"client_mutation_id": "0",
|
||||||
|
"actor_id": self.session.user.id,
|
||||||
|
"thread_fbid": self.id,
|
||||||
|
"user_ids": list(user_ids),
|
||||||
|
"response": "ACCEPT" if approve else "DENY",
|
||||||
|
"surface": "ADMIN_MODEL_APPROVAL_CENTER",
|
||||||
|
}
|
||||||
|
(j,) = self.session._graphql_requests(
|
||||||
|
_graphql.from_doc_id("1574519202665847", {"data": data})
|
||||||
|
)
|
||||||
|
|
||||||
|
def accept_users(self, user_ids: Iterable[str]):
|
||||||
|
"""Accept users to the group from the group's approval.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_ids: One or more user IDs to accept
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> group.accept_users(["1234", "2345"])
|
||||||
|
"""
|
||||||
|
self._users_approval(user_ids, True)
|
||||||
|
|
||||||
|
def deny_users(self, user_ids: Iterable[str]):
|
||||||
|
"""Deny users from joining the group.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_ids: One or more user IDs to deny
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> group.deny_users(["1234", "2345"])
|
||||||
|
"""
|
||||||
|
self._users_approval(user_ids, False)
|
||||||
|
|
||||||
|
|
||||||
|
@attrs_default
|
||||||
|
class GroupData(Group):
|
||||||
|
"""Represents data about a Facebook group.
|
||||||
|
|
||||||
|
Inherits `Group`, and implements `ThreadABC`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
#: The group's picture
|
||||||
|
photo = attr.ib(None, type=Optional[_models.Image])
|
||||||
|
#: The name of the group
|
||||||
|
name = attr.ib(None, type=Optional[str])
|
||||||
|
#: When the group was last active / when the last message was sent
|
||||||
|
last_active = attr.ib(None, type=Optional[datetime.datetime])
|
||||||
|
#: Number of messages in the group
|
||||||
|
message_count = attr.ib(None, type=Optional[int])
|
||||||
|
#: Set `Plan`
|
||||||
|
plan = attr.ib(None, type=Optional[_models.PlanData])
|
||||||
|
#: The group thread's participant user ids
|
||||||
|
participants = attr.ib(factory=set, type=Set[str])
|
||||||
|
#: A dictionary, containing user nicknames mapped to their IDs
|
||||||
|
nicknames = attr.ib(factory=dict, type=Mapping[str, str])
|
||||||
|
#: The groups's message color
|
||||||
|
color = attr.ib(None, type=Optional[str])
|
||||||
|
#: The groups's default emoji
|
||||||
|
emoji = attr.ib(None, type=Optional[str])
|
||||||
|
# User ids of thread admins
|
||||||
|
admins = attr.ib(factory=set, type=Set[str])
|
||||||
|
# True if users need approval to join
|
||||||
|
approval_mode = attr.ib(None, type=Optional[bool])
|
||||||
|
# Set containing user IDs requesting to join
|
||||||
|
approval_requests = attr.ib(factory=set, type=Set[str])
|
||||||
|
# Link for joining group
|
||||||
|
join_link = attr.ib(None, type=Optional[str])
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _from_graphql(cls, session, data):
|
||||||
|
if data.get("image") is None:
|
||||||
|
data["image"] = {}
|
||||||
|
c_info = cls._parse_customization_info(data)
|
||||||
|
last_active = None
|
||||||
|
if "last_message" in data:
|
||||||
|
last_active = _util.millis_to_datetime(
|
||||||
|
int(data["last_message"]["nodes"][0]["timestamp_precise"])
|
||||||
|
)
|
||||||
|
plan = None
|
||||||
|
if data.get("event_reminders") and data["event_reminders"].get("nodes"):
|
||||||
|
plan = _models.PlanData._from_graphql(
|
||||||
|
session, data["event_reminders"]["nodes"][0]
|
||||||
|
)
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
session=session,
|
||||||
|
id=data["thread_key"]["thread_fbid"],
|
||||||
|
participants=list(
|
||||||
|
cls._parse_participants(session, data["all_participants"])
|
||||||
|
),
|
||||||
|
nicknames=c_info.get("nicknames"),
|
||||||
|
color=c_info["color"],
|
||||||
|
emoji=c_info["emoji"],
|
||||||
|
admins=set([node.get("id") for node in data.get("thread_admins")]),
|
||||||
|
approval_mode=bool(data.get("approval_mode"))
|
||||||
|
if data.get("approval_mode") is not None
|
||||||
|
else None,
|
||||||
|
approval_requests=set(
|
||||||
|
node["requester"]["id"]
|
||||||
|
for node in data["group_approval_queue"]["nodes"]
|
||||||
|
)
|
||||||
|
if data.get("group_approval_queue")
|
||||||
|
else None,
|
||||||
|
join_link=data["joinable_mode"].get("link"),
|
||||||
|
photo=_models.Image._from_uri_or_none(data["image"]),
|
||||||
|
name=data.get("name"),
|
||||||
|
message_count=data.get("messages_count"),
|
||||||
|
last_active=last_active,
|
||||||
|
plan=plan,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@attrs_default
|
||||||
|
class NewGroup(ThreadABC):
|
||||||
|
"""Helper class to create new groups.
|
||||||
|
|
||||||
|
TODO: Complete this!
|
||||||
|
|
||||||
|
Construct this class with the desired users, and call a method like `wave`, to...
|
||||||
|
"""
|
||||||
|
|
||||||
|
#: The session to use when making requests.
|
||||||
|
session = attr.ib(type=_session.Session)
|
||||||
|
#: The users that should be added to the group.
|
||||||
|
_users = attr.ib(type=Sequence["_user.User"])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def id(self):
|
||||||
|
raise NotImplementedError(
|
||||||
|
"The method you called is not supported on NewGroup objects."
|
||||||
|
" Please use the supported methods to create the group, before attempting"
|
||||||
|
" to call the method."
|
||||||
|
)
|
||||||
|
|
||||||
|
def _to_send_data(self) -> dict:
|
||||||
|
return {
|
||||||
|
"specific_to_list[{}]".format(i): "fbid:{}".format(user.id)
|
||||||
|
for i, user in enumerate(self._users)
|
||||||
|
}
|
||||||
82
fbchat/_threads/_page.py
Normal file
82
fbchat/_threads/_page.py
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import attr
|
||||||
|
import datetime
|
||||||
|
from ._abc import ThreadABC
|
||||||
|
from .._common import attrs_default
|
||||||
|
from .. import _session, _models
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
@attrs_default
|
||||||
|
class Page(ThreadABC):
|
||||||
|
"""Represents a Facebook page. Implements `ThreadABC`.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> page = fbchat.Page(session=session, id="1234")
|
||||||
|
"""
|
||||||
|
|
||||||
|
# TODO: Implement pages properly, the implementation is lacking in a lot of places!
|
||||||
|
|
||||||
|
#: The session to use when making requests.
|
||||||
|
session = attr.ib(type=_session.Session)
|
||||||
|
#: The unique identifier of the page.
|
||||||
|
id = attr.ib(converter=str, type=str)
|
||||||
|
|
||||||
|
def _to_send_data(self):
|
||||||
|
return {"other_user_fbid": self.id}
|
||||||
|
|
||||||
|
def _copy(self) -> "Page":
|
||||||
|
return Page(session=self.session, id=self.id)
|
||||||
|
|
||||||
|
|
||||||
|
@attrs_default
|
||||||
|
class PageData(Page):
|
||||||
|
"""Represents data about a Facebook page.
|
||||||
|
|
||||||
|
Inherits `Page`, and implements `ThreadABC`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
#: The page's picture
|
||||||
|
photo = attr.ib(type=_models.Image)
|
||||||
|
#: The name of the page
|
||||||
|
name = attr.ib(type=str)
|
||||||
|
#: When the thread was last active / when the last message was sent
|
||||||
|
last_active = attr.ib(None, type=Optional[datetime.datetime])
|
||||||
|
#: Number of messages in the thread
|
||||||
|
message_count = attr.ib(None, type=Optional[int])
|
||||||
|
#: Set `Plan`
|
||||||
|
plan = attr.ib(None, type=Optional[_models.PlanData])
|
||||||
|
#: The page's custom URL
|
||||||
|
url = attr.ib(None, type=Optional[str])
|
||||||
|
#: The name of the page's location city
|
||||||
|
city = attr.ib(None, type=Optional[str])
|
||||||
|
#: Amount of likes the page has
|
||||||
|
likes = attr.ib(None, type=Optional[int])
|
||||||
|
#: Some extra information about the page
|
||||||
|
sub_title = attr.ib(None, type=Optional[str])
|
||||||
|
#: The page's category
|
||||||
|
category = attr.ib(None, type=Optional[str])
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _from_graphql(cls, session, data):
|
||||||
|
if data.get("profile_picture") is None:
|
||||||
|
data["profile_picture"] = {}
|
||||||
|
if data.get("city") is None:
|
||||||
|
data["city"] = {}
|
||||||
|
plan = None
|
||||||
|
if data.get("event_reminders") and data["event_reminders"].get("nodes"):
|
||||||
|
plan = _models.PlanData._from_graphql(
|
||||||
|
session, data["event_reminders"]["nodes"][0]
|
||||||
|
)
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
session=session,
|
||||||
|
id=data["id"],
|
||||||
|
url=data.get("url"),
|
||||||
|
city=data.get("city").get("name"),
|
||||||
|
category=data.get("category_type"),
|
||||||
|
photo=_models.Image._from_uri(data["profile_picture"]),
|
||||||
|
name=data["name"],
|
||||||
|
message_count=data.get("messages_count"),
|
||||||
|
plan=plan,
|
||||||
|
)
|
||||||
221
fbchat/_threads/_user.py
Normal file
221
fbchat/_threads/_user.py
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
import attr
|
||||||
|
import datetime
|
||||||
|
from ._abc import ThreadABC
|
||||||
|
from .._common import log, attrs_default
|
||||||
|
from .. import _util, _session, _models
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
GENDERS = {
|
||||||
|
# For standard requests
|
||||||
|
0: "unknown",
|
||||||
|
1: "female_singular",
|
||||||
|
2: "male_singular",
|
||||||
|
3: "female_singular_guess",
|
||||||
|
4: "male_singular_guess",
|
||||||
|
5: "mixed",
|
||||||
|
6: "neuter_singular",
|
||||||
|
7: "unknown_singular",
|
||||||
|
8: "female_plural",
|
||||||
|
9: "male_plural",
|
||||||
|
10: "neuter_plural",
|
||||||
|
11: "unknown_plural",
|
||||||
|
# For graphql requests
|
||||||
|
"UNKNOWN": "unknown",
|
||||||
|
"FEMALE": "female_singular",
|
||||||
|
"MALE": "male_singular",
|
||||||
|
# '': 'female_singular_guess',
|
||||||
|
# '': 'male_singular_guess',
|
||||||
|
# '': 'mixed',
|
||||||
|
"NEUTER": "neuter_singular",
|
||||||
|
# '': 'unknown_singular',
|
||||||
|
# '': 'female_plural',
|
||||||
|
# '': 'male_plural',
|
||||||
|
# '': 'neuter_plural',
|
||||||
|
# '': 'unknown_plural',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@attrs_default
|
||||||
|
class User(ThreadABC):
|
||||||
|
"""Represents a Facebook user. Implements `ThreadABC`.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> user = fbchat.User(session=session, id="1234")
|
||||||
|
"""
|
||||||
|
|
||||||
|
#: The session to use when making requests.
|
||||||
|
session = attr.ib(type=_session.Session)
|
||||||
|
#: The user's unique identifier.
|
||||||
|
id = attr.ib(converter=str, type=str)
|
||||||
|
|
||||||
|
def _to_send_data(self):
|
||||||
|
return {
|
||||||
|
"other_user_fbid": self.id,
|
||||||
|
# The entry below is to support .wave
|
||||||
|
"specific_to_list[0]": "fbid:{}".format(self.id),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _copy(self) -> "User":
|
||||||
|
return User(session=self.session, id=self.id)
|
||||||
|
|
||||||
|
def confirm_friend_request(self):
|
||||||
|
"""Confirm a friend request, adding the user to your friend list.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> user.confirm_friend_request()
|
||||||
|
"""
|
||||||
|
data = {"to_friend": self.id, "action": "confirm"}
|
||||||
|
j = self.session._payload_post("/ajax/add_friend/action.php?dpr=1", data)
|
||||||
|
|
||||||
|
def remove_friend(self):
|
||||||
|
"""Remove the user from the client's friend list.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> user.remove_friend()
|
||||||
|
"""
|
||||||
|
data = {"uid": self.id}
|
||||||
|
j = self.session._payload_post("/ajax/profile/removefriendconfirm.php", data)
|
||||||
|
|
||||||
|
def block(self):
|
||||||
|
"""Block messages from the user.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> user.block()
|
||||||
|
"""
|
||||||
|
data = {"fbid": self.id}
|
||||||
|
j = self.session._payload_post("/messaging/block_messages/?dpr=1", data)
|
||||||
|
|
||||||
|
def unblock(self):
|
||||||
|
"""Unblock a previously blocked user.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> user.unblock()
|
||||||
|
"""
|
||||||
|
data = {"fbid": self.id}
|
||||||
|
j = self.session._payload_post("/messaging/unblock_messages/?dpr=1", data)
|
||||||
|
|
||||||
|
|
||||||
|
@attrs_default
|
||||||
|
class UserData(User):
|
||||||
|
"""Represents data about a Facebook user.
|
||||||
|
|
||||||
|
Inherits `User`, and implements `ThreadABC`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
#: The user's picture
|
||||||
|
photo = attr.ib(type=_models.Image)
|
||||||
|
#: The name of the user
|
||||||
|
name = attr.ib(type=str)
|
||||||
|
#: Whether the user and the client are friends
|
||||||
|
is_friend = attr.ib(type=bool)
|
||||||
|
#: The users first name
|
||||||
|
first_name = attr.ib(type=str)
|
||||||
|
#: The users last name
|
||||||
|
last_name = attr.ib(None, type=Optional[str])
|
||||||
|
#: When the thread was last active / when the last message was sent
|
||||||
|
last_active = attr.ib(None, type=Optional[datetime.datetime])
|
||||||
|
#: Number of messages in the thread
|
||||||
|
message_count = attr.ib(None, type=Optional[int])
|
||||||
|
#: Set `Plan`
|
||||||
|
plan = attr.ib(None, type=Optional[_models.PlanData])
|
||||||
|
#: The profile URL. ``None`` for Messenger-only users
|
||||||
|
url = attr.ib(None, type=Optional[str])
|
||||||
|
#: The user's gender
|
||||||
|
gender = attr.ib(None, type=Optional[str])
|
||||||
|
#: From 0 to 1. How close the client is to the user
|
||||||
|
affinity = attr.ib(None, type=Optional[float])
|
||||||
|
#: The user's nickname
|
||||||
|
nickname = attr.ib(None, type=Optional[str])
|
||||||
|
#: The clients nickname, as seen by the user
|
||||||
|
own_nickname = attr.ib(None, type=Optional[str])
|
||||||
|
#: The message color
|
||||||
|
color = attr.ib(None, type=Optional[str])
|
||||||
|
#: The default emoji
|
||||||
|
emoji = attr.ib(None, type=Optional[str])
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_other_user(data):
|
||||||
|
(user,) = (
|
||||||
|
node["messaging_actor"]
|
||||||
|
for node in data["all_participants"]["nodes"]
|
||||||
|
if node["messaging_actor"]["id"] == data["thread_key"]["other_user_id"]
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _from_graphql(cls, session, data):
|
||||||
|
c_info = cls._parse_customization_info(data)
|
||||||
|
|
||||||
|
plan = None
|
||||||
|
if data.get("event_reminders") and data["event_reminders"].get("nodes"):
|
||||||
|
plan = _models.PlanData._from_graphql(
|
||||||
|
session, data["event_reminders"]["nodes"][0]
|
||||||
|
)
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
session=session,
|
||||||
|
id=data["id"],
|
||||||
|
url=data["url"],
|
||||||
|
first_name=data["first_name"],
|
||||||
|
last_name=data.get("last_name"),
|
||||||
|
is_friend=data["is_viewer_friend"],
|
||||||
|
gender=GENDERS.get(data["gender"]),
|
||||||
|
affinity=data.get("viewer_affinity"),
|
||||||
|
nickname=c_info.get("nickname"),
|
||||||
|
color=c_info["color"],
|
||||||
|
emoji=c_info["emoji"],
|
||||||
|
own_nickname=c_info.get("own_nickname"),
|
||||||
|
photo=_models.Image._from_uri(data["profile_picture"]),
|
||||||
|
name=data["name"],
|
||||||
|
message_count=data.get("messages_count"),
|
||||||
|
plan=plan,
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _from_thread_fetch(cls, session, data):
|
||||||
|
user = cls._get_other_user(data)
|
||||||
|
if user["__typename"] != "User":
|
||||||
|
# TODO: Add Page._from_thread_fetch, and parse it there
|
||||||
|
log.warning("Tried to parse %s as a user.", user["__typename"])
|
||||||
|
return None
|
||||||
|
|
||||||
|
c_info = cls._parse_customization_info(data)
|
||||||
|
|
||||||
|
plan = None
|
||||||
|
if data["event_reminders"]["nodes"]:
|
||||||
|
plan = _models.PlanData._from_graphql(
|
||||||
|
session, data["event_reminders"]["nodes"][0]
|
||||||
|
)
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
session=session,
|
||||||
|
id=user["id"],
|
||||||
|
url=user["url"],
|
||||||
|
name=user["name"],
|
||||||
|
first_name=user["short_name"],
|
||||||
|
is_friend=user["is_viewer_friend"],
|
||||||
|
gender=GENDERS.get(user["gender"]),
|
||||||
|
nickname=c_info.get("nickname"),
|
||||||
|
color=c_info["color"],
|
||||||
|
emoji=c_info["emoji"],
|
||||||
|
own_nickname=c_info.get("own_nickname"),
|
||||||
|
photo=_models.Image._from_uri(user["big_image_src"]),
|
||||||
|
message_count=data["messages_count"],
|
||||||
|
last_active=_util.millis_to_datetime(int(data["updated_time_precise"])),
|
||||||
|
plan=plan,
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _from_all_fetch(cls, session, data):
|
||||||
|
return cls(
|
||||||
|
session=session,
|
||||||
|
id=data["id"],
|
||||||
|
first_name=data["firstName"],
|
||||||
|
url=data["uri"],
|
||||||
|
photo=_models.Image(url=data["thumbSrc"]),
|
||||||
|
name=data["name"],
|
||||||
|
is_friend=data["is_friend"],
|
||||||
|
gender=GENDERS.get(data["gender"]),
|
||||||
|
)
|
||||||
208
fbchat/_user.py
208
fbchat/_user.py
@@ -1,208 +0,0 @@
|
|||||||
# -*- coding: UTF-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
import attr
|
|
||||||
from ._core import Enum
|
|
||||||
from . import _plan
|
|
||||||
from ._thread import ThreadType, Thread
|
|
||||||
|
|
||||||
|
|
||||||
GENDERS = {
|
|
||||||
# For standard requests
|
|
||||||
0: "unknown",
|
|
||||||
1: "female_singular",
|
|
||||||
2: "male_singular",
|
|
||||||
3: "female_singular_guess",
|
|
||||||
4: "male_singular_guess",
|
|
||||||
5: "mixed",
|
|
||||||
6: "neuter_singular",
|
|
||||||
7: "unknown_singular",
|
|
||||||
8: "female_plural",
|
|
||||||
9: "male_plural",
|
|
||||||
10: "neuter_plural",
|
|
||||||
11: "unknown_plural",
|
|
||||||
# For graphql requests
|
|
||||||
"UNKNOWN": "unknown",
|
|
||||||
"FEMALE": "female_singular",
|
|
||||||
"MALE": "male_singular",
|
|
||||||
# '': 'female_singular_guess',
|
|
||||||
# '': 'male_singular_guess',
|
|
||||||
# '': 'mixed',
|
|
||||||
"NEUTER": "neuter_singular",
|
|
||||||
# '': 'unknown_singular',
|
|
||||||
# '': 'female_plural',
|
|
||||||
# '': 'male_plural',
|
|
||||||
# '': 'neuter_plural',
|
|
||||||
# '': 'unknown_plural',
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class TypingStatus(Enum):
|
|
||||||
"""Used to specify whether the user is typing or has stopped typing"""
|
|
||||||
|
|
||||||
STOPPED = 0
|
|
||||||
TYPING = 1
|
|
||||||
|
|
||||||
|
|
||||||
@attr.s(cmp=False, init=False)
|
|
||||||
class User(Thread):
|
|
||||||
"""Represents a Facebook user. Inherits `Thread`"""
|
|
||||||
|
|
||||||
#: The profile url
|
|
||||||
url = attr.ib(None)
|
|
||||||
#: The users first name
|
|
||||||
first_name = attr.ib(None)
|
|
||||||
#: The users last name
|
|
||||||
last_name = attr.ib(None)
|
|
||||||
#: Whether the user and the client are friends
|
|
||||||
is_friend = attr.ib(None)
|
|
||||||
#: The user's gender
|
|
||||||
gender = attr.ib(None)
|
|
||||||
#: From 0 to 1. How close the client is to the user
|
|
||||||
affinity = attr.ib(None)
|
|
||||||
#: The user's nickname
|
|
||||||
nickname = attr.ib(None)
|
|
||||||
#: The clients nickname, as seen by the user
|
|
||||||
own_nickname = attr.ib(None)
|
|
||||||
#: A :class:`ThreadColor`. The message color
|
|
||||||
color = attr.ib(None)
|
|
||||||
#: The default emoji
|
|
||||||
emoji = attr.ib(None)
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
uid,
|
|
||||||
url=None,
|
|
||||||
first_name=None,
|
|
||||||
last_name=None,
|
|
||||||
is_friend=None,
|
|
||||||
gender=None,
|
|
||||||
affinity=None,
|
|
||||||
nickname=None,
|
|
||||||
own_nickname=None,
|
|
||||||
color=None,
|
|
||||||
emoji=None,
|
|
||||||
**kwargs
|
|
||||||
):
|
|
||||||
super(User, self).__init__(ThreadType.USER, uid, **kwargs)
|
|
||||||
self.url = url
|
|
||||||
self.first_name = first_name
|
|
||||||
self.last_name = last_name
|
|
||||||
self.is_friend = is_friend
|
|
||||||
self.gender = gender
|
|
||||||
self.affinity = affinity
|
|
||||||
self.nickname = nickname
|
|
||||||
self.own_nickname = own_nickname
|
|
||||||
self.color = color
|
|
||||||
self.emoji = emoji
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _from_graphql(cls, data):
|
|
||||||
if data.get("profile_picture") is None:
|
|
||||||
data["profile_picture"] = {}
|
|
||||||
c_info = cls._parse_customization_info(data)
|
|
||||||
plan = None
|
|
||||||
if data.get("event_reminders") and data["event_reminders"].get("nodes"):
|
|
||||||
plan = _plan.Plan._from_graphql(data["event_reminders"]["nodes"][0])
|
|
||||||
|
|
||||||
return cls(
|
|
||||||
data["id"],
|
|
||||||
url=data.get("url"),
|
|
||||||
first_name=data.get("first_name"),
|
|
||||||
last_name=data.get("last_name"),
|
|
||||||
is_friend=data.get("is_viewer_friend"),
|
|
||||||
gender=GENDERS.get(data.get("gender")),
|
|
||||||
affinity=data.get("affinity"),
|
|
||||||
nickname=c_info.get("nickname"),
|
|
||||||
color=c_info.get("color"),
|
|
||||||
emoji=c_info.get("emoji"),
|
|
||||||
own_nickname=c_info.get("own_nickname"),
|
|
||||||
photo=data["profile_picture"].get("uri"),
|
|
||||||
name=data.get("name"),
|
|
||||||
message_count=data.get("messages_count"),
|
|
||||||
plan=plan,
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _from_thread_fetch(cls, data):
|
|
||||||
if data.get("big_image_src") is None:
|
|
||||||
data["big_image_src"] = {}
|
|
||||||
c_info = cls._parse_customization_info(data)
|
|
||||||
participants = [
|
|
||||||
node["messaging_actor"] for node in data["all_participants"]["nodes"]
|
|
||||||
]
|
|
||||||
user = next(
|
|
||||||
p for p in participants if p["id"] == data["thread_key"]["other_user_id"]
|
|
||||||
)
|
|
||||||
last_message_timestamp = None
|
|
||||||
if "last_message" in data:
|
|
||||||
last_message_timestamp = data["last_message"]["nodes"][0][
|
|
||||||
"timestamp_precise"
|
|
||||||
]
|
|
||||||
|
|
||||||
first_name = user.get("short_name")
|
|
||||||
if first_name is None:
|
|
||||||
last_name = None
|
|
||||||
else:
|
|
||||||
last_name = user.get("name").split(first_name, 1).pop().strip()
|
|
||||||
|
|
||||||
plan = None
|
|
||||||
if data.get("event_reminders") and data["event_reminders"].get("nodes"):
|
|
||||||
plan = _plan.Plan._from_graphql(data["event_reminders"]["nodes"][0])
|
|
||||||
|
|
||||||
return cls(
|
|
||||||
user["id"],
|
|
||||||
url=user.get("url"),
|
|
||||||
name=user.get("name"),
|
|
||||||
first_name=first_name,
|
|
||||||
last_name=last_name,
|
|
||||||
is_friend=user.get("is_viewer_friend"),
|
|
||||||
gender=GENDERS.get(user.get("gender")),
|
|
||||||
affinity=user.get("affinity"),
|
|
||||||
nickname=c_info.get("nickname"),
|
|
||||||
color=c_info.get("color"),
|
|
||||||
emoji=c_info.get("emoji"),
|
|
||||||
own_nickname=c_info.get("own_nickname"),
|
|
||||||
photo=user["big_image_src"].get("uri"),
|
|
||||||
message_count=data.get("messages_count"),
|
|
||||||
last_message_timestamp=last_message_timestamp,
|
|
||||||
plan=plan,
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _from_all_fetch(cls, data):
|
|
||||||
return cls(
|
|
||||||
data["id"],
|
|
||||||
first_name=data.get("firstName"),
|
|
||||||
url=data.get("uri"),
|
|
||||||
photo=data.get("thumbSrc"),
|
|
||||||
name=data.get("name"),
|
|
||||||
is_friend=data.get("is_friend"),
|
|
||||||
gender=GENDERS.get(data.get("gender")),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@attr.s(cmp=False)
|
|
||||||
class ActiveStatus(object):
|
|
||||||
#: Whether the user is active now
|
|
||||||
active = attr.ib(None)
|
|
||||||
#: Timestamp when the user was last active
|
|
||||||
last_active = attr.ib(None)
|
|
||||||
#: Whether the user is playing Messenger game now
|
|
||||||
in_game = attr.ib(None)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _from_chatproxy_presence(cls, id_, data):
|
|
||||||
return cls(
|
|
||||||
active=data["p"] in [2, 3] if "p" in data else 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,
|
|
||||||
)
|
|
||||||
336
fbchat/_util.py
336
fbchat/_util.py
@@ -1,208 +1,94 @@
|
|||||||
# -*- coding: UTF-8 -*-
|
import datetime
|
||||||
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
import re
|
|
||||||
import json
|
import json
|
||||||
from time import time
|
import time
|
||||||
from random import random
|
import random
|
||||||
from contextlib import contextmanager
|
import urllib.parse
|
||||||
from mimetypes import guess_type
|
|
||||||
from os.path import basename
|
|
||||||
import warnings
|
|
||||||
import logging
|
|
||||||
import requests
|
|
||||||
from ._exception import (
|
|
||||||
FBchatException,
|
|
||||||
FBchatFacebookError,
|
|
||||||
FBchatInvalidParameters,
|
|
||||||
FBchatNotLoggedIn,
|
|
||||||
FBchatPleaseRefresh,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
from ._common import log
|
||||||
from urllib.parse import urlencode, parse_qs, urlparse
|
from . import _exception
|
||||||
|
|
||||||
basestring = (str, bytes)
|
from typing import Iterable, Optional, Any, Mapping, Sequence
|
||||||
except ImportError:
|
|
||||||
from urllib import urlencode
|
|
||||||
from urlparse import parse_qs, urlparse
|
|
||||||
|
|
||||||
basestring = basestring
|
|
||||||
|
|
||||||
# Python 2's `input` executes the input, whereas `raw_input` just returns the input
|
|
||||||
try:
|
|
||||||
input = raw_input
|
|
||||||
except NameError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Log settings
|
|
||||||
log = logging.getLogger("client")
|
|
||||||
log.setLevel(logging.DEBUG)
|
|
||||||
# Creates the console handler
|
|
||||||
handler = logging.StreamHandler()
|
|
||||||
log.addHandler(handler)
|
|
||||||
|
|
||||||
#: Default list of user agents
|
|
||||||
USER_AGENTS = [
|
|
||||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2311.90 Safari/537.36",
|
|
||||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_3) AppleWebKit/601.1.10 (KHTML, like Gecko) Version/8.0.5 Safari/601.1.10",
|
|
||||||
"Mozilla/5.0 (Windows NT 6.3; WOW64; ; NCT50_AAP285C84A1328) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2311.90 Safari/537.36",
|
|
||||||
"Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.1 (KHTML, like Gecko) Chrome/22.0.1207.1 Safari/537.1",
|
|
||||||
"Mozilla/5.0 (X11; CrOS i686 2268.111.0) AppleWebKit/536.11 (KHTML, like Gecko) Chrome/20.0.1132.57 Safari/536.11",
|
|
||||||
"Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/536.6 (KHTML, like Gecko) Chrome/20.0.1092.0 Safari/536.6",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def now():
|
def int_or_none(inp: Any) -> Optional[int]:
|
||||||
return int(time() * 1000)
|
try:
|
||||||
|
return int(inp)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def strip_json_cruft(text):
|
def get_limits(limit: Optional[int], max_limit: int) -> Iterable[int]:
|
||||||
|
"""Helper that generates limits based on a max limit."""
|
||||||
|
if limit is None:
|
||||||
|
# Generate infinite items
|
||||||
|
while True:
|
||||||
|
yield max_limit
|
||||||
|
|
||||||
|
if limit < 0:
|
||||||
|
raise ValueError("Limit cannot be negative")
|
||||||
|
|
||||||
|
# Generate n items
|
||||||
|
yield from [max_limit] * (limit // max_limit)
|
||||||
|
|
||||||
|
remainder = limit % max_limit
|
||||||
|
if remainder:
|
||||||
|
yield remainder
|
||||||
|
|
||||||
|
|
||||||
|
def json_minimal(data: Any) -> str:
|
||||||
|
"""Get JSON data in minimal form."""
|
||||||
|
return json.dumps(data, separators=(",", ":"))
|
||||||
|
|
||||||
|
|
||||||
|
def strip_json_cruft(text: str) -> str:
|
||||||
"""Removes `for(;;);` (and other cruft) that preceeds JSON responses."""
|
"""Removes `for(;;);` (and other cruft) that preceeds JSON responses."""
|
||||||
try:
|
try:
|
||||||
return text[text.index("{") :]
|
return text[text.index("{") :]
|
||||||
except ValueError:
|
except ValueError as e:
|
||||||
raise FBchatException("No JSON object found: {!r}".format(text))
|
raise _exception.ParseError("No JSON object found", data=text) from e
|
||||||
|
|
||||||
|
|
||||||
def get_decoded_r(r):
|
def parse_json(text: str) -> Any:
|
||||||
return get_decoded(r._content)
|
|
||||||
|
|
||||||
|
|
||||||
def get_decoded(content):
|
|
||||||
return content.decode("utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def parse_json(content):
|
|
||||||
try:
|
try:
|
||||||
return json.loads(content)
|
return json.loads(text)
|
||||||
except ValueError:
|
except ValueError as e:
|
||||||
raise FBchatFacebookError("Error while parsing JSON: {!r}".format(content))
|
raise _exception.ParseError("Error while parsing JSON", data=text) from e
|
||||||
|
|
||||||
|
|
||||||
def digitToChar(digit):
|
def generate_offline_threading_id():
|
||||||
if digit < 10:
|
ret = datetime_to_millis(now())
|
||||||
return str(digit)
|
value = int(random.random() * 4294967295)
|
||||||
return chr(ord("a") + digit - 10)
|
|
||||||
|
|
||||||
|
|
||||||
def str_base(number, base):
|
|
||||||
if number < 0:
|
|
||||||
return "-" + str_base(-number, base)
|
|
||||||
(d, m) = divmod(number, base)
|
|
||||||
if d > 0:
|
|
||||||
return str_base(d, base) + digitToChar(m)
|
|
||||||
return digitToChar(m)
|
|
||||||
|
|
||||||
|
|
||||||
def generateMessageID(client_id=None):
|
|
||||||
k = now()
|
|
||||||
l = int(random() * 4294967295)
|
|
||||||
return "<{}:{}-{}@mail.projektitan.com>".format(k, l, client_id)
|
|
||||||
|
|
||||||
|
|
||||||
def getSignatureID():
|
|
||||||
return hex(int(random() * 2147483648))
|
|
||||||
|
|
||||||
|
|
||||||
def generateOfflineThreadingID():
|
|
||||||
ret = now()
|
|
||||||
value = int(random() * 4294967295)
|
|
||||||
string = ("0000000000000000000000" + format(value, "b"))[-22:]
|
string = ("0000000000000000000000" + format(value, "b"))[-22:]
|
||||||
msgs = format(ret, "b") + string
|
msgs = format(ret, "b") + string
|
||||||
return str(int(msgs, 2))
|
return str(int(msgs, 2))
|
||||||
|
|
||||||
|
|
||||||
def handle_payload_error(j):
|
def remove_version_from_module(module):
|
||||||
if "error" not in j:
|
return module.split("@", 1)[0]
|
||||||
return
|
|
||||||
error = j["error"]
|
|
||||||
if j["error"] == 1357001:
|
|
||||||
error_cls = FBchatNotLoggedIn
|
|
||||||
elif j["error"] == 1357004:
|
|
||||||
error_cls = FBchatPleaseRefresh
|
|
||||||
elif j["error"] in (1357031, 1545010, 1545003):
|
|
||||||
error_cls = FBchatInvalidParameters
|
|
||||||
else:
|
|
||||||
error_cls = FBchatFacebookError
|
|
||||||
# TODO: Use j["errorSummary"]
|
|
||||||
# "errorDescription" is in the users own language!
|
|
||||||
raise error_cls(
|
|
||||||
"Error #{} when sending request: {}".format(error, j["errorDescription"]),
|
|
||||||
fb_error_code=error,
|
|
||||||
fb_error_message=j["errorDescription"],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def handle_graphql_errors(j):
|
def get_jsmods_require(require) -> Mapping[str, Sequence[Any]]:
|
||||||
errors = []
|
rtn = {}
|
||||||
if j.get("error"):
|
for item in require:
|
||||||
errors = [j["error"]]
|
if len(item) == 1:
|
||||||
if "errors" in j:
|
(module,) = item
|
||||||
errors = j["errors"]
|
rtn[remove_version_from_module(module)] = []
|
||||||
if errors:
|
continue
|
||||||
error = errors[0] # TODO: Handle multiple errors
|
module, method, requirements, arguments = item
|
||||||
# TODO: Use `summary`, `severity` and `description`
|
method = "{}.{}".format(remove_version_from_module(module), method)
|
||||||
raise FBchatFacebookError(
|
rtn[method] = arguments
|
||||||
"GraphQL error #{}: {} / {!r}".format(
|
return rtn
|
||||||
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):
|
def get_jsmods_define(define) -> Mapping[str, Mapping[str, Any]]:
|
||||||
check_http_code(r.status_code)
|
rtn = {}
|
||||||
content = get_decoded_r(r)
|
for item in define:
|
||||||
check_content(content)
|
module, requirements, data, _ = item
|
||||||
return content
|
rtn[module] = data
|
||||||
|
return rtn
|
||||||
|
|
||||||
|
|
||||||
def check_http_code(code):
|
def mimetype_to_key(mimetype: str) -> str:
|
||||||
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:
|
|
||||||
raise FBchatFacebookError("Error when sending request: Got empty response")
|
|
||||||
|
|
||||||
|
|
||||||
def to_json(content):
|
|
||||||
content = strip_json_cruft(content)
|
|
||||||
j = parse_json(content)
|
|
||||||
log.debug(j)
|
|
||||||
return j
|
|
||||||
|
|
||||||
|
|
||||||
def get_jsmods_require(j, index):
|
|
||||||
if j.get("jsmods") and j["jsmods"].get("require"):
|
|
||||||
try:
|
|
||||||
return j["jsmods"]["require"][0][index][0]
|
|
||||||
except (KeyError, IndexError) as e:
|
|
||||||
log.warning(
|
|
||||||
"Error when getting jsmods_require: "
|
|
||||||
"{}. Facebook might have changed protocol".format(j)
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def require_list(list_):
|
|
||||||
if isinstance(list_, list):
|
|
||||||
return set(list_)
|
|
||||||
else:
|
|
||||||
return set([list_])
|
|
||||||
|
|
||||||
|
|
||||||
def mimetype_to_key(mimetype):
|
|
||||||
if not mimetype:
|
if not mimetype:
|
||||||
return "file_id"
|
return "file_id"
|
||||||
if mimetype == "image/gif":
|
if mimetype == "image/gif":
|
||||||
@@ -213,44 +99,70 @@ def mimetype_to_key(mimetype):
|
|||||||
return "file_id"
|
return "file_id"
|
||||||
|
|
||||||
|
|
||||||
def get_files_from_urls(file_urls):
|
def get_url_parameter(url: str, param: str) -> Optional[str]:
|
||||||
files = []
|
params = urllib.parse.parse_qs(urllib.parse.urlparse(url).query)
|
||||||
for file_url in file_urls:
|
if not params.get(param):
|
||||||
r = requests.get(file_url)
|
return None
|
||||||
# We could possibly use r.headers.get('Content-Disposition'), see
|
return params[param][0]
|
||||||
# https://stackoverflow.com/a/37060758
|
|
||||||
files.append(
|
|
||||||
(
|
|
||||||
basename(file_url).split("?")[0].split("#")[0],
|
|
||||||
r.content,
|
|
||||||
r.headers.get("Content-Type") or guess_type(file_url)[0],
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return files
|
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
def seconds_to_datetime(timestamp_in_seconds: float) -> datetime.datetime:
|
||||||
def get_files_from_paths(filenames):
|
"""Convert an UTC timestamp to a timezone-aware datetime object."""
|
||||||
files = []
|
# `.utcfromtimestamp` will return a "naive" datetime object, which is why we use the
|
||||||
for filename in filenames:
|
# following:
|
||||||
files.append(
|
return datetime.datetime.fromtimestamp(
|
||||||
(basename(filename), open(filename, "rb"), guess_type(filename)[0])
|
timestamp_in_seconds, tz=datetime.timezone.utc
|
||||||
)
|
)
|
||||||
yield files
|
|
||||||
for fn, fp, ft in files:
|
|
||||||
fp.close()
|
|
||||||
|
|
||||||
|
|
||||||
def get_url_parameters(url, *args):
|
def millis_to_datetime(timestamp_in_milliseconds: int) -> datetime.datetime:
|
||||||
params = parse_qs(urlparse(url).query)
|
"""Convert an UTC timestamp, in milliseconds, to a timezone-aware datetime."""
|
||||||
return [params[arg][0] for arg in args if params.get(arg)]
|
return seconds_to_datetime(timestamp_in_milliseconds / 1000)
|
||||||
|
|
||||||
|
|
||||||
def get_url_parameter(url, param):
|
def datetime_to_seconds(dt: datetime.datetime) -> int:
|
||||||
return get_url_parameters(url, param)[0]
|
"""Convert a datetime to an UTC timestamp.
|
||||||
|
|
||||||
|
Naive datetime objects are presumed to represent time in the system timezone.
|
||||||
|
|
||||||
|
The returned seconds will be rounded to the nearest whole number.
|
||||||
|
"""
|
||||||
|
# We could've implemented some fancy "convert naive timezones to UTC" logic, but
|
||||||
|
# it's not really worth the effort.
|
||||||
|
return round(dt.timestamp())
|
||||||
|
|
||||||
|
|
||||||
def prefix_url(url):
|
def datetime_to_millis(dt: datetime.datetime) -> int:
|
||||||
if url.startswith("/"):
|
"""Convert a datetime to an UTC timestamp, in milliseconds.
|
||||||
return "https://www.facebook.com" + url
|
|
||||||
return url
|
Naive datetime objects are presumed to represent time in the system timezone.
|
||||||
|
|
||||||
|
The returned milliseconds will be rounded to the nearest whole number.
|
||||||
|
"""
|
||||||
|
return round(dt.timestamp() * 1000)
|
||||||
|
|
||||||
|
|
||||||
|
def seconds_to_timedelta(seconds: float) -> datetime.timedelta:
|
||||||
|
"""Convert seconds to a timedelta."""
|
||||||
|
return datetime.timedelta(seconds=seconds)
|
||||||
|
|
||||||
|
|
||||||
|
def millis_to_timedelta(milliseconds: int) -> datetime.timedelta:
|
||||||
|
"""Convert a duration (in milliseconds) to a timedelta object."""
|
||||||
|
return datetime.timedelta(milliseconds=milliseconds)
|
||||||
|
|
||||||
|
|
||||||
|
def timedelta_to_seconds(td: datetime.timedelta) -> int:
|
||||||
|
"""Convert a timedelta to seconds.
|
||||||
|
|
||||||
|
The returned seconds will be rounded to the nearest whole number.
|
||||||
|
"""
|
||||||
|
return round(td.total_seconds())
|
||||||
|
|
||||||
|
|
||||||
|
def now() -> datetime.datetime:
|
||||||
|
"""The current time.
|
||||||
|
|
||||||
|
Similar to datetime.datetime.now(), but returns a non-naive datetime.
|
||||||
|
"""
|
||||||
|
return datetime.datetime.now(tz=datetime.timezone.utc)
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
# -*- coding: UTF-8 -*-
|
|
||||||
"""This file is here to maintain backwards compatability, and to re-export our models
|
|
||||||
into the global module (see `__init__.py`).
|
|
||||||
|
|
||||||
A common pattern was to use `from fbchat.models import *`, hence we need this while
|
|
||||||
transitioning to a better code structure.
|
|
||||||
"""
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
from ._core import Enum
|
|
||||||
from ._exception import FBchatException, FBchatFacebookError, FBchatUserError
|
|
||||||
from ._thread import ThreadType, ThreadLocation, ThreadColor, Thread
|
|
||||||
from ._user import TypingStatus, User, ActiveStatus
|
|
||||||
from ._group import Group, Room
|
|
||||||
from ._page import Page
|
|
||||||
from ._message import EmojiSize, MessageReaction, Mention, Message
|
|
||||||
from ._attachment import Attachment, UnsentMessage, ShareAttachment
|
|
||||||
from ._sticker import Sticker
|
|
||||||
from ._location import LocationAttachment, LiveLocationAttachment
|
|
||||||
from ._file import FileAttachment, AudioAttachment, ImageAttachment, VideoAttachment
|
|
||||||
from ._quick_reply import (
|
|
||||||
QuickReply,
|
|
||||||
QuickReplyText,
|
|
||||||
QuickReplyLocation,
|
|
||||||
QuickReplyPhoneNumber,
|
|
||||||
QuickReplyEmail,
|
|
||||||
)
|
|
||||||
from ._poll import Poll, PollOption
|
|
||||||
from ._plan import GuestStatus, Plan
|
|
||||||
0
fbchat/py.typed
Normal file
0
fbchat/py.typed
Normal file
@@ -1,5 +1,6 @@
|
|||||||
[tool.black]
|
[tool.black]
|
||||||
line-length = 88
|
line-length = 88
|
||||||
|
target-version = ['py36', 'py37', 'py38']
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["flit"]
|
requires = ["flit"]
|
||||||
@@ -11,12 +12,12 @@ author = "Taehoon Kim"
|
|||||||
author-email = "carpedm20@gmail.com"
|
author-email = "carpedm20@gmail.com"
|
||||||
maintainer = "Mads Marquart"
|
maintainer = "Mads Marquart"
|
||||||
maintainer-email = "madsmtm@gmail.com"
|
maintainer-email = "madsmtm@gmail.com"
|
||||||
home-page = "https://github.com/carpedm20/fbchat/"
|
home-page = "https://git.karaolidis.com/karaolidis/fbchat/"
|
||||||
requires = [
|
requires = [
|
||||||
"aenum~=2.0",
|
"attrs>=19.1",
|
||||||
"attrs>=18.2",
|
|
||||||
"requests~=2.19",
|
"requests~=2.19",
|
||||||
"beautifulsoup4~=4.0",
|
"beautifulsoup4~=4.0",
|
||||||
|
"paho-mqtt~=1.5",
|
||||||
]
|
]
|
||||||
description-file = "README.rst"
|
description-file = "README.rst"
|
||||||
classifiers = [
|
classifiers = [
|
||||||
@@ -27,12 +28,12 @@ classifiers = [
|
|||||||
"Operating System :: OS Independent",
|
"Operating System :: OS Independent",
|
||||||
"Natural Language :: English",
|
"Natural Language :: English",
|
||||||
"Programming Language :: Python",
|
"Programming Language :: Python",
|
||||||
"Programming Language :: Python :: 2.7",
|
|
||||||
"Programming Language :: Python :: 3",
|
"Programming Language :: Python :: 3",
|
||||||
"Programming Language :: Python :: 3.4",
|
"Programming Language :: Python :: 3 :: Only",
|
||||||
"Programming Language :: Python :: 3.5",
|
"Programming Language :: Python :: 3.5",
|
||||||
"Programming Language :: Python :: 3.6",
|
"Programming Language :: Python :: 3.6",
|
||||||
"Programming Language :: Python :: 3.7",
|
"Programming Language :: Python :: 3.7",
|
||||||
|
"Programming Language :: Python :: 3.8",
|
||||||
"Programming Language :: Python :: Implementation :: CPython",
|
"Programming Language :: Python :: Implementation :: CPython",
|
||||||
"Programming Language :: Python :: Implementation :: PyPy",
|
"Programming Language :: Python :: Implementation :: PyPy",
|
||||||
"Topic :: Communications :: Chat",
|
"Topic :: Communications :: Chat",
|
||||||
@@ -41,26 +42,22 @@ classifiers = [
|
|||||||
"Topic :: Software Development :: Libraries",
|
"Topic :: Software Development :: Libraries",
|
||||||
"Topic :: Software Development :: Libraries :: Python Modules",
|
"Topic :: Software Development :: Libraries :: Python Modules",
|
||||||
]
|
]
|
||||||
requires-python = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, <4.0"
|
requires-python = ">=3.5, <4.0"
|
||||||
keywords = "Facebook FB Messenger Library Chat Api Bot"
|
keywords = "Facebook FB Messenger Library Chat Api Bot"
|
||||||
license = "BSD 3-Clause"
|
license = "BSD 3-Clause"
|
||||||
|
|
||||||
[tool.flit.metadata.urls]
|
[tool.flit.metadata.urls]
|
||||||
Documentation = "https://fbchat.readthedocs.io/"
|
Repository = "https://git.karaolidis.com/karaolidis/fbchat/"
|
||||||
Repository = "https://github.com/carpedm20/fbchat/"
|
|
||||||
|
|
||||||
[tool.flit.metadata.requires-extra]
|
[tool.flit.metadata.requires-extra]
|
||||||
test = [
|
test = [
|
||||||
"pytest~=4.0",
|
"pytest>=4.3,<6.0",
|
||||||
"six~=1.0",
|
|
||||||
]
|
]
|
||||||
docs = [
|
docs = [
|
||||||
"sphinx~=2.0",
|
"sphinx~=2.0",
|
||||||
|
"sphinxcontrib-spelling~=4.0",
|
||||||
|
"sphinx-autodoc-typehints~=1.10",
|
||||||
]
|
]
|
||||||
lint = [
|
lint = [
|
||||||
"black",
|
"black",
|
||||||
]
|
]
|
||||||
tools = [
|
|
||||||
# Fork of bumpversion, see https://github.com/c4urself/bump2version
|
|
||||||
"bump2version~=0.5.0",
|
|
||||||
]
|
|
||||||
|
|||||||
12
pytest.ini
12
pytest.ini
@@ -1,6 +1,10 @@
|
|||||||
[pytest]
|
[pytest]
|
||||||
xfail_strict=true
|
xfail_strict = true
|
||||||
markers =
|
markers =
|
||||||
offline: Offline tests, aka. tests that can be executed without the need of a client
|
online: Online tests, that require a user account set up. Meant to be used \
|
||||||
expensive: Expensive tests, which should be executed sparingly
|
manually, to check whether Facebook has broken something.
|
||||||
addopts = -m "not expensive"
|
addopts =
|
||||||
|
--strict
|
||||||
|
-m "not online"
|
||||||
|
testpaths = tests
|
||||||
|
filterwarnings = error
|
||||||
|
|||||||
@@ -1,129 +1,9 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import json
|
import fbchat
|
||||||
|
|
||||||
from utils import *
|
|
||||||
from contextlib import contextmanager
|
|
||||||
from fbchat.models import ThreadType, Message, Mention
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
def user(client2):
|
def session():
|
||||||
return {"id": client2.uid, "type": ThreadType.USER}
|
return fbchat.Session(
|
||||||
|
user_id="31415926536", fb_dtsg=None, revision=None, session=None
|
||||||
|
)
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def group(pytestconfig):
|
|
||||||
return {
|
|
||||||
"id": load_variable("group_id", pytestconfig.cache),
|
|
||||||
"type": ThreadType.GROUP,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(
|
|
||||||
scope="session",
|
|
||||||
params=["user", "group", pytest.param("none", marks=[pytest.mark.xfail()])],
|
|
||||||
)
|
|
||||||
def thread(request, user, group):
|
|
||||||
return {
|
|
||||||
"user": user,
|
|
||||||
"group": group,
|
|
||||||
"none": {"id": "0", "type": ThreadType.GROUP},
|
|
||||||
}[request.param]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def client1(pytestconfig):
|
|
||||||
with load_client(1, pytestconfig.cache) as c:
|
|
||||||
yield c
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def client2(pytestconfig):
|
|
||||||
with load_client(2, pytestconfig.cache) as c:
|
|
||||||
yield c
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
|
||||||
def client(client1, thread):
|
|
||||||
client1.setDefaultThread(thread["id"], thread["type"])
|
|
||||||
yield client1
|
|
||||||
client1.resetDefaultThread()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session", params=["client1", "client2"])
|
|
||||||
def client_all(request, client1, client2):
|
|
||||||
return client1 if request.param == "client1" else client2
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def catch_event(client2):
|
|
||||||
t = ClientThread(client2)
|
|
||||||
t.start()
|
|
||||||
|
|
||||||
@contextmanager
|
|
||||||
def inner(method_name):
|
|
||||||
caught = CaughtValue()
|
|
||||||
old_method = getattr(client2, method_name)
|
|
||||||
|
|
||||||
# Will be called by the other thread
|
|
||||||
def catch_value(*args, **kwargs):
|
|
||||||
old_method(*args, **kwargs)
|
|
||||||
# Make sure the `set` is only called once
|
|
||||||
if not caught.is_set():
|
|
||||||
caught.set(kwargs)
|
|
||||||
|
|
||||||
setattr(client2, method_name, catch_value)
|
|
||||||
yield caught
|
|
||||||
caught.wait()
|
|
||||||
if not caught.is_set():
|
|
||||||
raise ValueError("The value could not be caught")
|
|
||||||
setattr(client2, method_name, old_method)
|
|
||||||
|
|
||||||
yield inner
|
|
||||||
|
|
||||||
t.should_stop.set()
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Make the client send a messages to itself, so the blocking pull request will return
|
|
||||||
# This is probably not safe, since the client is making two requests simultaneously
|
|
||||||
client2.sendMessage(random_hex(), client2.uid)
|
|
||||||
finally:
|
|
||||||
t.join()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
|
||||||
def compare(client, thread):
|
|
||||||
def inner(caught_event, **kwargs):
|
|
||||||
d = {
|
|
||||||
"author_id": client.uid,
|
|
||||||
"thread_id": client.uid
|
|
||||||
if thread["type"] == ThreadType.USER
|
|
||||||
else thread["id"],
|
|
||||||
"thread_type": thread["type"],
|
|
||||||
}
|
|
||||||
d.update(kwargs)
|
|
||||||
return subset(caught_event.res, **d)
|
|
||||||
|
|
||||||
return inner
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(params=["me", "other", "me other"])
|
|
||||||
def message_with_mentions(request, client, client2, group):
|
|
||||||
text = "Hi there ["
|
|
||||||
mentions = []
|
|
||||||
if "me" in request.param:
|
|
||||||
mentions.append(Mention(thread_id=client.uid, offset=len(text), length=2))
|
|
||||||
text += "me, "
|
|
||||||
if "other" in request.param:
|
|
||||||
mentions.append(Mention(thread_id=client2.uid, offset=len(text), length=5))
|
|
||||||
text += "other, "
|
|
||||||
# Unused, because Facebook don't properly support sending mentions with groups as targets
|
|
||||||
if "group" in request.param:
|
|
||||||
mentions.append(Mention(thread_id=group["id"], offset=len(text), length=5))
|
|
||||||
text += "group, "
|
|
||||||
text += "nothing]"
|
|
||||||
return Message(text, mentions=mentions)
|
|
||||||
|
|||||||
175
tests/events/test_client_payload.py
Normal file
175
tests/events/test_client_payload.py
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import datetime
|
||||||
|
import pytest
|
||||||
|
from fbchat import (
|
||||||
|
ParseError,
|
||||||
|
User,
|
||||||
|
Group,
|
||||||
|
Message,
|
||||||
|
MessageData,
|
||||||
|
UnknownEvent,
|
||||||
|
ReactionEvent,
|
||||||
|
UserStatusEvent,
|
||||||
|
LiveLocationEvent,
|
||||||
|
UnsendEvent,
|
||||||
|
MessageReplyEvent,
|
||||||
|
)
|
||||||
|
from fbchat._events import parse_client_delta, parse_client_payloads
|
||||||
|
|
||||||
|
|
||||||
|
def test_reaction_event_added(session):
|
||||||
|
data = {
|
||||||
|
"threadKey": {"otherUserFbId": 1234},
|
||||||
|
"messageId": "mid.$XYZ",
|
||||||
|
"action": 0,
|
||||||
|
"userId": 4321,
|
||||||
|
"reaction": "😍",
|
||||||
|
"senderId": 4321,
|
||||||
|
"offlineThreadingId": "6623596674408921967",
|
||||||
|
}
|
||||||
|
thread = User(session=session, id="1234")
|
||||||
|
assert ReactionEvent(
|
||||||
|
author=User(session=session, id="4321"),
|
||||||
|
thread=thread,
|
||||||
|
message=Message(thread=thread, id="mid.$XYZ"),
|
||||||
|
reaction="😍",
|
||||||
|
) == parse_client_delta(session, {"deltaMessageReaction": data})
|
||||||
|
|
||||||
|
|
||||||
|
def test_reaction_event_removed(session):
|
||||||
|
data = {
|
||||||
|
"threadKey": {"threadFbId": 1234},
|
||||||
|
"messageId": "mid.$XYZ",
|
||||||
|
"action": 1,
|
||||||
|
"userId": 4321,
|
||||||
|
"senderId": 4321,
|
||||||
|
"offlineThreadingId": "6623586106713014836",
|
||||||
|
}
|
||||||
|
thread = Group(session=session, id="1234")
|
||||||
|
assert ReactionEvent(
|
||||||
|
author=User(session=session, id="4321"),
|
||||||
|
thread=thread,
|
||||||
|
message=Message(thread=thread, id="mid.$XYZ"),
|
||||||
|
reaction=None,
|
||||||
|
) == parse_client_delta(session, {"deltaMessageReaction": data})
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_status_blocked(session):
|
||||||
|
data = {
|
||||||
|
"threadKey": {"otherUserFbId": 1234},
|
||||||
|
"canViewerReply": False,
|
||||||
|
"reason": 2,
|
||||||
|
"actorFbid": 4321,
|
||||||
|
}
|
||||||
|
assert UserStatusEvent(
|
||||||
|
author=User(session=session, id="4321"),
|
||||||
|
thread=User(session=session, id="1234"),
|
||||||
|
blocked=True,
|
||||||
|
) == parse_client_delta(session, {"deltaChangeViewerStatus": data})
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_status_unblocked(session):
|
||||||
|
data = {
|
||||||
|
"threadKey": {"otherUserFbId": 1234},
|
||||||
|
"canViewerReply": True,
|
||||||
|
"reason": 2,
|
||||||
|
"actorFbid": 1234,
|
||||||
|
}
|
||||||
|
assert UserStatusEvent(
|
||||||
|
author=User(session=session, id="1234"),
|
||||||
|
thread=User(session=session, id="1234"),
|
||||||
|
blocked=False,
|
||||||
|
) == parse_client_delta(session, {"deltaChangeViewerStatus": data})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skip(reason="need to gather test data")
|
||||||
|
def test_live_location(session):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def test_message_reply(session):
|
||||||
|
message = {
|
||||||
|
"messageMetadata": {
|
||||||
|
"threadKey": {"otherUserFbId": 1234},
|
||||||
|
"messageId": "mid.$XYZ",
|
||||||
|
"offlineThreadingId": "112233445566",
|
||||||
|
"actorFbId": 1234,
|
||||||
|
"timestamp": 1500000000000,
|
||||||
|
"tags": ["source:messenger:web", "cg-enabled", "sent", "inbox"],
|
||||||
|
"threadReadStateEffect": 3,
|
||||||
|
"skipBumpThread": False,
|
||||||
|
"skipSnippetUpdate": False,
|
||||||
|
"unsendType": "can_unsend",
|
||||||
|
"folderId": {"systemFolderId": 0},
|
||||||
|
},
|
||||||
|
"body": "xyz",
|
||||||
|
"attachments": [],
|
||||||
|
"irisSeqId": 1111111,
|
||||||
|
"messageReply": {"replyToMessageId": {"id": "mid.$ABC"}, "status": 0,},
|
||||||
|
"requestContext": {"apiArgs": "..."},
|
||||||
|
"irisTags": ["DeltaNewMessage"],
|
||||||
|
}
|
||||||
|
reply = {
|
||||||
|
"messageMetadata": {
|
||||||
|
"threadKey": {"otherUserFbId": 1234},
|
||||||
|
"messageId": "mid.$ABC",
|
||||||
|
"offlineThreadingId": "665544332211",
|
||||||
|
"actorFbId": 4321,
|
||||||
|
"timestamp": 1600000000000,
|
||||||
|
"tags": ["inbox", "sent", "source:messenger:web"],
|
||||||
|
},
|
||||||
|
"body": "abc",
|
||||||
|
"attachments": [],
|
||||||
|
"requestContext": {"apiArgs": "..."},
|
||||||
|
"irisTags": [],
|
||||||
|
}
|
||||||
|
data = {
|
||||||
|
"message": message,
|
||||||
|
"repliedToMessage": reply,
|
||||||
|
"status": 0,
|
||||||
|
}
|
||||||
|
thread = User(session=session, id="1234")
|
||||||
|
assert MessageReplyEvent(
|
||||||
|
author=User(session=session, id="1234"),
|
||||||
|
thread=thread,
|
||||||
|
message=MessageData(
|
||||||
|
thread=thread,
|
||||||
|
id="mid.$XYZ",
|
||||||
|
author="1234",
|
||||||
|
created_at=datetime.datetime(
|
||||||
|
2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc
|
||||||
|
),
|
||||||
|
text="xyz",
|
||||||
|
reply_to_id="mid.$ABC",
|
||||||
|
),
|
||||||
|
replied_to=MessageData(
|
||||||
|
thread=thread,
|
||||||
|
id="mid.$ABC",
|
||||||
|
author="4321",
|
||||||
|
created_at=datetime.datetime(
|
||||||
|
2020, 9, 13, 12, 26, 40, tzinfo=datetime.timezone.utc
|
||||||
|
),
|
||||||
|
text="abc",
|
||||||
|
),
|
||||||
|
) == parse_client_delta(session, {"deltaMessageReply": data})
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_client_delta_unknown(session):
|
||||||
|
assert UnknownEvent(
|
||||||
|
source="client payload", data={"abc": 10}
|
||||||
|
) == parse_client_delta(session, {"abc": 10})
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_client_payloads_empty(session):
|
||||||
|
# This is never something that happens, it's just so that we can test the parsing
|
||||||
|
# payload = '{"deltas":[]}'
|
||||||
|
payload = [123, 34, 100, 101, 108, 116, 97, 115, 34, 58, 91, 93, 125]
|
||||||
|
data = {"payload": payload, "class": "ClientPayload"}
|
||||||
|
assert [] == list(parse_client_payloads(session, data))
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_client_payloads_invalid(session):
|
||||||
|
# payload = '{"invalid":"data"}'
|
||||||
|
payload = [123, 34, 105, 110, 118, 97, 108, 105, 100, 34, 58, 34, 97, 34, 125]
|
||||||
|
data = {"payload": payload, "class": "ClientPayload"}
|
||||||
|
with pytest.raises(ParseError, match="Error parsing ClientPayload"):
|
||||||
|
list(parse_client_payloads(session, data))
|
||||||
78
tests/events/test_common.py
Normal file
78
tests/events/test_common.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import pytest
|
||||||
|
import datetime
|
||||||
|
from fbchat import Group, User, ParseError, Event, ThreadEvent
|
||||||
|
|
||||||
|
|
||||||
|
def test_event_get_thread_group1(session):
|
||||||
|
data = {
|
||||||
|
"threadKey": {"threadFbId": 1234},
|
||||||
|
"messageId": "mid.$gAAT4Sw1WSGh14A3MOFvrsiDvr3Yc",
|
||||||
|
"offlineThreadingId": "6623583531508397596",
|
||||||
|
"actorFbId": 4321,
|
||||||
|
"timestamp": 1500000000000,
|
||||||
|
"tags": [
|
||||||
|
"inbox",
|
||||||
|
"sent",
|
||||||
|
"tq",
|
||||||
|
"blindly_apply_message_folder",
|
||||||
|
"source:messenger:web",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
assert Group(session=session, id="1234") == Event._get_thread(session, data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_event_get_thread_group2(session):
|
||||||
|
data = {
|
||||||
|
"actorFbId": "4321",
|
||||||
|
"folderId": {"systemFolderId": "INBOX"},
|
||||||
|
"messageId": "mid.$XYZ",
|
||||||
|
"offlineThreadingId": "112233445566",
|
||||||
|
"skipBumpThread": False,
|
||||||
|
"tags": ["source:messenger:web"],
|
||||||
|
"threadKey": {"threadFbId": "1234"},
|
||||||
|
"threadReadStateEffect": "KEEP_AS_IS",
|
||||||
|
"timestamp": "1500000000000",
|
||||||
|
}
|
||||||
|
assert Group(session=session, id="1234") == Event._get_thread(session, data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_event_get_thread_user(session):
|
||||||
|
data = {
|
||||||
|
"actorFbId": "4321",
|
||||||
|
"folderId": {"systemFolderId": "INBOX"},
|
||||||
|
"messageId": "mid.$XYZ",
|
||||||
|
"offlineThreadingId": "112233445566",
|
||||||
|
"skipBumpThread": False,
|
||||||
|
"skipSnippetUpdate": False,
|
||||||
|
"tags": ["source:messenger:web"],
|
||||||
|
"threadKey": {"otherUserFbId": "1234"},
|
||||||
|
"threadReadStateEffect": "KEEP_AS_IS",
|
||||||
|
"timestamp": "1500000000000",
|
||||||
|
}
|
||||||
|
assert User(session=session, id="1234") == Event._get_thread(session, data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_event_get_thread_unknown(session):
|
||||||
|
data = {"threadKey": {"abc": "1234"}}
|
||||||
|
with pytest.raises(ParseError, match="Could not find thread data"):
|
||||||
|
Event._get_thread(session, data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_thread_event_parse_metadata(session):
|
||||||
|
data = {
|
||||||
|
"actorFbId": "4321",
|
||||||
|
"folderId": {"systemFolderId": "INBOX"},
|
||||||
|
"messageId": "mid.$XYZ",
|
||||||
|
"offlineThreadingId": "112233445566",
|
||||||
|
"skipBumpThread": False,
|
||||||
|
"skipSnippetUpdate": False,
|
||||||
|
"tags": ["source:messenger:web"],
|
||||||
|
"threadKey": {"otherUserFbId": "1234"},
|
||||||
|
"threadReadStateEffect": "KEEP_AS_IS",
|
||||||
|
"timestamp": "1500000000000",
|
||||||
|
}
|
||||||
|
assert (
|
||||||
|
User(session=session, id="4321"),
|
||||||
|
User(session=session, id="1234"),
|
||||||
|
datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc),
|
||||||
|
) == ThreadEvent._parse_metadata(session, {"messageMetadata": data})
|
||||||
359
tests/events/test_delta_class.py
Normal file
359
tests/events/test_delta_class.py
Normal file
@@ -0,0 +1,359 @@
|
|||||||
|
import datetime
|
||||||
|
import pytest
|
||||||
|
from fbchat import (
|
||||||
|
ParseError,
|
||||||
|
User,
|
||||||
|
Group,
|
||||||
|
Message,
|
||||||
|
MessageData,
|
||||||
|
ThreadLocation,
|
||||||
|
UnknownEvent,
|
||||||
|
PeopleAdded,
|
||||||
|
PersonRemoved,
|
||||||
|
TitleSet,
|
||||||
|
UnfetchedThreadEvent,
|
||||||
|
MessagesDelivered,
|
||||||
|
ThreadsRead,
|
||||||
|
MessageEvent,
|
||||||
|
ThreadFolder,
|
||||||
|
)
|
||||||
|
from fbchat._events import parse_delta
|
||||||
|
|
||||||
|
|
||||||
|
def test_people_added(session):
|
||||||
|
data = {
|
||||||
|
"addedParticipants": [
|
||||||
|
{
|
||||||
|
"fanoutPolicy": "IRIS_MESSAGE_QUEUE",
|
||||||
|
"firstName": "Abc",
|
||||||
|
"fullName": "Abc Def",
|
||||||
|
"initialFolder": "FOLDER_INBOX",
|
||||||
|
"initialFolderId": {"systemFolderId": "INBOX"},
|
||||||
|
"isMessengerUser": False,
|
||||||
|
"userFbId": "1234",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"irisSeqId": "11223344",
|
||||||
|
"irisTags": ["DeltaParticipantsAddedToGroupThread", "is_from_iris_fanout"],
|
||||||
|
"messageMetadata": {
|
||||||
|
"actorFbId": "3456",
|
||||||
|
"adminText": "You added Abc Def to the group.",
|
||||||
|
"folderId": {"systemFolderId": "INBOX"},
|
||||||
|
"messageId": "mid.$XYZ",
|
||||||
|
"offlineThreadingId": "1122334455",
|
||||||
|
"skipBumpThread": False,
|
||||||
|
"tags": [],
|
||||||
|
"threadKey": {"threadFbId": "4321"},
|
||||||
|
"threadReadStateEffect": "KEEP_AS_IS",
|
||||||
|
"timestamp": "1500000000000",
|
||||||
|
"unsendType": "deny_log_message",
|
||||||
|
},
|
||||||
|
"participants": ["1234", "2345", "3456", "4567"],
|
||||||
|
"requestContext": {"apiArgs": {}},
|
||||||
|
"tqSeqId": "1111",
|
||||||
|
"class": "ParticipantsAddedToGroupThread",
|
||||||
|
}
|
||||||
|
assert PeopleAdded(
|
||||||
|
author=User(session=session, id="3456"),
|
||||||
|
thread=Group(session=session, id="4321"),
|
||||||
|
added=[User(session=session, id="1234")],
|
||||||
|
at=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc),
|
||||||
|
) == parse_delta(session, data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_person_removed(session):
|
||||||
|
data = {
|
||||||
|
"irisSeqId": "11223344",
|
||||||
|
"irisTags": ["DeltaParticipantLeftGroupThread", "is_from_iris_fanout"],
|
||||||
|
"leftParticipantFbId": "1234",
|
||||||
|
"messageMetadata": {
|
||||||
|
"actorFbId": "3456",
|
||||||
|
"adminText": "You removed Abc Def from the group.",
|
||||||
|
"folderId": {"systemFolderId": "INBOX"},
|
||||||
|
"messageId": "mid.$XYZ",
|
||||||
|
"offlineThreadingId": "1122334455",
|
||||||
|
"skipBumpThread": True,
|
||||||
|
"tags": [],
|
||||||
|
"threadKey": {"threadFbId": "4321"},
|
||||||
|
"threadReadStateEffect": "KEEP_AS_IS",
|
||||||
|
"timestamp": "1500000000000",
|
||||||
|
"unsendType": "deny_log_message",
|
||||||
|
},
|
||||||
|
"participants": ["1234", "2345", "3456", "4567"],
|
||||||
|
"requestContext": {"apiArgs": {}},
|
||||||
|
"tqSeqId": "1111",
|
||||||
|
"class": "ParticipantLeftGroupThread",
|
||||||
|
}
|
||||||
|
assert PersonRemoved(
|
||||||
|
author=User(session=session, id="3456"),
|
||||||
|
thread=Group(session=session, id="4321"),
|
||||||
|
removed=User(session=session, id="1234"),
|
||||||
|
at=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc),
|
||||||
|
) == parse_delta(session, data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_title_set(session):
|
||||||
|
data = {
|
||||||
|
"irisSeqId": "11223344",
|
||||||
|
"irisTags": ["DeltaThreadName", "is_from_iris_fanout"],
|
||||||
|
"messageMetadata": {
|
||||||
|
"actorFbId": "3456",
|
||||||
|
"adminText": "You named the group abc.",
|
||||||
|
"folderId": {"systemFolderId": "INBOX"},
|
||||||
|
"messageId": "mid.$XYZ",
|
||||||
|
"offlineThreadingId": "1122334455",
|
||||||
|
"skipBumpThread": False,
|
||||||
|
"tags": [],
|
||||||
|
"threadKey": {"threadFbId": "4321"},
|
||||||
|
"threadReadStateEffect": "KEEP_AS_IS",
|
||||||
|
"timestamp": "1500000000000",
|
||||||
|
"unsendType": "deny_log_message",
|
||||||
|
},
|
||||||
|
"name": "abc",
|
||||||
|
"participants": ["1234", "2345", "3456", "4567"],
|
||||||
|
"requestContext": {"apiArgs": {}},
|
||||||
|
"tqSeqId": "1111",
|
||||||
|
"class": "ThreadName",
|
||||||
|
}
|
||||||
|
assert TitleSet(
|
||||||
|
author=User(session=session, id="3456"),
|
||||||
|
thread=Group(session=session, id="4321"),
|
||||||
|
title="abc",
|
||||||
|
at=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc),
|
||||||
|
) == parse_delta(session, data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_title_removed(session):
|
||||||
|
data = {
|
||||||
|
"irisSeqId": "11223344",
|
||||||
|
"irisTags": ["DeltaThreadName", "is_from_iris_fanout"],
|
||||||
|
"messageMetadata": {
|
||||||
|
"actorFbId": "3456",
|
||||||
|
"adminText": "You removed the group name.",
|
||||||
|
"folderId": {"systemFolderId": "INBOX"},
|
||||||
|
"messageId": "mid.$XYZ",
|
||||||
|
"offlineThreadingId": "1122334455",
|
||||||
|
"skipBumpThread": False,
|
||||||
|
"tags": [],
|
||||||
|
"threadKey": {"threadFbId": "4321"},
|
||||||
|
"threadReadStateEffect": "KEEP_AS_IS",
|
||||||
|
"timestamp": "1500000000000",
|
||||||
|
"unsendType": "deny_log_message",
|
||||||
|
},
|
||||||
|
"name": "",
|
||||||
|
"participants": ["1234", "2345", "3456", "4567"],
|
||||||
|
"requestContext": {"apiArgs": {}},
|
||||||
|
"tqSeqId": "1111",
|
||||||
|
"class": "ThreadName",
|
||||||
|
}
|
||||||
|
assert TitleSet(
|
||||||
|
author=User(session=session, id="3456"),
|
||||||
|
thread=Group(session=session, id="4321"),
|
||||||
|
title=None,
|
||||||
|
at=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc),
|
||||||
|
) == parse_delta(session, data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_forced_fetch(session):
|
||||||
|
data = {
|
||||||
|
"forceInsert": False,
|
||||||
|
"messageId": "mid.$XYZ",
|
||||||
|
"threadKey": {"threadFbId": "1234"},
|
||||||
|
"class": "ForcedFetch",
|
||||||
|
}
|
||||||
|
thread = Group(session=session, id="1234")
|
||||||
|
assert UnfetchedThreadEvent(
|
||||||
|
thread=thread, message=Message(thread=thread, id="mid.$XYZ")
|
||||||
|
) == parse_delta(session, data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_forced_fetch_pending(session):
|
||||||
|
data = {
|
||||||
|
"forceInsert": False,
|
||||||
|
"irisSeqId": "1111",
|
||||||
|
"isLazy": False,
|
||||||
|
"threadKey": {"threadFbId": "1234"},
|
||||||
|
"class": "ForcedFetch",
|
||||||
|
}
|
||||||
|
assert UnfetchedThreadEvent(
|
||||||
|
thread=Group(session=session, id="1234"), message=None
|
||||||
|
) == parse_delta(session, data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_delivery_receipt_group(session):
|
||||||
|
data = {
|
||||||
|
"actorFbId": "1234",
|
||||||
|
"deliveredWatermarkTimestampMs": "1500000000000",
|
||||||
|
"irisSeqId": "1111111",
|
||||||
|
"irisTags": ["DeltaDeliveryReceipt"],
|
||||||
|
"messageIds": ["mid.$XYZ", "mid.$ABC"],
|
||||||
|
"requestContext": {"apiArgs": {}},
|
||||||
|
"threadKey": {"threadFbId": "4321"},
|
||||||
|
"class": "DeliveryReceipt",
|
||||||
|
}
|
||||||
|
thread = Group(session=session, id="4321")
|
||||||
|
assert MessagesDelivered(
|
||||||
|
author=User(session=session, id="1234"),
|
||||||
|
thread=thread,
|
||||||
|
messages=[
|
||||||
|
Message(thread=thread, id="mid.$XYZ"),
|
||||||
|
Message(thread=thread, id="mid.$ABC"),
|
||||||
|
],
|
||||||
|
at=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc),
|
||||||
|
) == parse_delta(session, data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_delivery_receipt_user(session):
|
||||||
|
data = {
|
||||||
|
"deliveredWatermarkTimestampMs": "1500000000000",
|
||||||
|
"irisSeqId": "1111111",
|
||||||
|
"irisTags": ["DeltaDeliveryReceipt", "is_from_iris_fanout"],
|
||||||
|
"messageIds": ["mid.$XYZ", "mid.$ABC"],
|
||||||
|
"requestContext": {"apiArgs": {}},
|
||||||
|
"threadKey": {"otherUserFbId": "1234"},
|
||||||
|
"class": "DeliveryReceipt",
|
||||||
|
}
|
||||||
|
thread = User(session=session, id="1234")
|
||||||
|
assert MessagesDelivered(
|
||||||
|
author=thread,
|
||||||
|
thread=thread,
|
||||||
|
messages=[
|
||||||
|
Message(thread=thread, id="mid.$XYZ"),
|
||||||
|
Message(thread=thread, id="mid.$ABC"),
|
||||||
|
],
|
||||||
|
at=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc),
|
||||||
|
) == parse_delta(session, data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_receipt(session):
|
||||||
|
data = {
|
||||||
|
"actionTimestampMs": "1600000000000",
|
||||||
|
"actorFbId": "1234",
|
||||||
|
"irisSeqId": "1111111",
|
||||||
|
"irisTags": ["DeltaReadReceipt", "is_from_iris_fanout"],
|
||||||
|
"requestContext": {"apiArgs": {}},
|
||||||
|
"threadKey": {"threadFbId": "4321"},
|
||||||
|
"tqSeqId": "1111",
|
||||||
|
"watermarkTimestampMs": "1500000000000",
|
||||||
|
"class": "ReadReceipt",
|
||||||
|
}
|
||||||
|
assert ThreadsRead(
|
||||||
|
author=User(session=session, id="1234"),
|
||||||
|
threads=[Group(session=session, id="4321")],
|
||||||
|
at=datetime.datetime(2020, 9, 13, 12, 26, 40, tzinfo=datetime.timezone.utc),
|
||||||
|
) == parse_delta(session, data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_mark_read(session):
|
||||||
|
data = {
|
||||||
|
"actionTimestamp": "1600000000000",
|
||||||
|
"irisSeqId": "1111111",
|
||||||
|
"irisTags": ["DeltaMarkRead", "is_from_iris_fanout"],
|
||||||
|
"threadKeys": [{"threadFbId": "1234"}, {"otherUserFbId": "2345"}],
|
||||||
|
"tqSeqId": "1111",
|
||||||
|
"watermarkTimestamp": "1500000000000",
|
||||||
|
"class": "MarkRead",
|
||||||
|
}
|
||||||
|
assert ThreadsRead(
|
||||||
|
author=session.user,
|
||||||
|
threads=[Group(session=session, id="1234"), User(session=session, id="2345")],
|
||||||
|
at=datetime.datetime(2020, 9, 13, 12, 26, 40, tzinfo=datetime.timezone.utc),
|
||||||
|
) == parse_delta(session, data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_message_user(session):
|
||||||
|
data = {
|
||||||
|
"attachments": [],
|
||||||
|
"body": "test",
|
||||||
|
"irisSeqId": "1111111",
|
||||||
|
"irisTags": ["DeltaNewMessage"],
|
||||||
|
"messageMetadata": {
|
||||||
|
"actorFbId": "1234",
|
||||||
|
"folderId": {"systemFolderId": "INBOX"},
|
||||||
|
"messageId": "mid.$XYZ",
|
||||||
|
"offlineThreadingId": "11223344556677889900",
|
||||||
|
"skipBumpThread": False,
|
||||||
|
"skipSnippetUpdate": False,
|
||||||
|
"tags": ["source:messenger:web"],
|
||||||
|
"threadKey": {"otherUserFbId": "1234"},
|
||||||
|
"threadReadStateEffect": "KEEP_AS_IS",
|
||||||
|
"timestamp": "1600000000000",
|
||||||
|
},
|
||||||
|
"requestContext": {"apiArgs": {}},
|
||||||
|
"class": "NewMessage",
|
||||||
|
}
|
||||||
|
assert MessageEvent(
|
||||||
|
author=User(session=session, id="1234"),
|
||||||
|
thread=User(session=session, id="1234"),
|
||||||
|
message=MessageData(
|
||||||
|
thread=User(session=session, id="1234"),
|
||||||
|
id="mid.$XYZ",
|
||||||
|
author="1234",
|
||||||
|
text="test",
|
||||||
|
created_at=datetime.datetime(
|
||||||
|
2020, 9, 13, 12, 26, 40, tzinfo=datetime.timezone.utc
|
||||||
|
),
|
||||||
|
),
|
||||||
|
at=datetime.datetime(2020, 9, 13, 12, 26, 40, tzinfo=datetime.timezone.utc),
|
||||||
|
) == parse_delta(session, data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_message_group(session):
|
||||||
|
data = {
|
||||||
|
"attachments": [],
|
||||||
|
"body": "test",
|
||||||
|
"irisSeqId": "1111111",
|
||||||
|
"irisTags": ["DeltaNewMessage", "is_from_iris_fanout"],
|
||||||
|
"messageMetadata": {
|
||||||
|
"actorFbId": "4321",
|
||||||
|
"folderId": {"systemFolderId": "INBOX"},
|
||||||
|
"messageId": "mid.$XYZ",
|
||||||
|
"offlineThreadingId": "11223344556677889900",
|
||||||
|
"skipBumpThread": False,
|
||||||
|
"tags": ["source:messenger:web"],
|
||||||
|
"threadKey": {"threadFbId": "1234"},
|
||||||
|
"threadReadStateEffect": "KEEP_AS_IS",
|
||||||
|
"timestamp": "1600000000000",
|
||||||
|
},
|
||||||
|
"participants": ["4321", "5432", "6543"],
|
||||||
|
"requestContext": {"apiArgs": {}},
|
||||||
|
"tqSeqId": "1111",
|
||||||
|
"class": "NewMessage",
|
||||||
|
}
|
||||||
|
assert MessageEvent(
|
||||||
|
author=User(session=session, id="4321"),
|
||||||
|
thread=Group(session=session, id="1234"),
|
||||||
|
message=MessageData(
|
||||||
|
thread=Group(session=session, id="1234"),
|
||||||
|
id="mid.$XYZ",
|
||||||
|
author="4321",
|
||||||
|
text="test",
|
||||||
|
created_at=datetime.datetime(
|
||||||
|
2020, 9, 13, 12, 26, 40, tzinfo=datetime.timezone.utc
|
||||||
|
),
|
||||||
|
),
|
||||||
|
at=datetime.datetime(2020, 9, 13, 12, 26, 40, tzinfo=datetime.timezone.utc),
|
||||||
|
) == parse_delta(session, data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_thread_folder(session):
|
||||||
|
data = {
|
||||||
|
"class": "ThreadFolder",
|
||||||
|
"folder": "FOLDER_PENDING",
|
||||||
|
"irisSeqId": "1111",
|
||||||
|
"irisTags": ["DeltaThreadFolder", "is_from_iris_fanout"],
|
||||||
|
"requestContext": {"apiArgs": {}},
|
||||||
|
"threadKey": {"otherUserFbId": "1234"},
|
||||||
|
}
|
||||||
|
assert ThreadFolder(
|
||||||
|
thread=User(session=session, id="1234"), folder=ThreadLocation.PENDING
|
||||||
|
) == parse_delta(session, data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_noop(session):
|
||||||
|
assert parse_delta(session, {"class": "NoOp"}) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_delta_unknown(session):
|
||||||
|
data = {"class": "Abc"}
|
||||||
|
assert UnknownEvent(source="Delta class", data=data) == parse_delta(session, data)
|
||||||
958
tests/events/test_delta_type.py
Normal file
958
tests/events/test_delta_type.py
Normal file
@@ -0,0 +1,958 @@
|
|||||||
|
import datetime
|
||||||
|
import pytest
|
||||||
|
from fbchat import (
|
||||||
|
_util,
|
||||||
|
ParseError,
|
||||||
|
User,
|
||||||
|
Group,
|
||||||
|
Message,
|
||||||
|
MessageData,
|
||||||
|
Poll,
|
||||||
|
PollOption,
|
||||||
|
PlanData,
|
||||||
|
GuestStatus,
|
||||||
|
UnknownEvent,
|
||||||
|
ColorSet,
|
||||||
|
EmojiSet,
|
||||||
|
NicknameSet,
|
||||||
|
AdminsAdded,
|
||||||
|
AdminsRemoved,
|
||||||
|
ApprovalModeSet,
|
||||||
|
CallStarted,
|
||||||
|
CallEnded,
|
||||||
|
CallJoined,
|
||||||
|
PollCreated,
|
||||||
|
PollVoted,
|
||||||
|
PlanCreated,
|
||||||
|
PlanEnded,
|
||||||
|
PlanEdited,
|
||||||
|
PlanDeleted,
|
||||||
|
PlanResponded,
|
||||||
|
)
|
||||||
|
from fbchat._events import parse_admin_message
|
||||||
|
|
||||||
|
|
||||||
|
def test_color_set(session):
|
||||||
|
data = {
|
||||||
|
"irisSeqId": "1111111",
|
||||||
|
"irisTags": ["DeltaAdminTextMessage", "is_from_iris_fanout"],
|
||||||
|
"messageMetadata": {
|
||||||
|
"actorFbId": "1234",
|
||||||
|
"adminText": "You changed the chat theme to Orange.",
|
||||||
|
"folderId": {"systemFolderId": "INBOX"},
|
||||||
|
"messageId": "mid.$XYZ",
|
||||||
|
"offlineThreadingId": "11223344556677889900",
|
||||||
|
"skipBumpThread": False,
|
||||||
|
"tags": ["source:titan:web", "no_push"],
|
||||||
|
"threadKey": {"threadFbId": "4321"},
|
||||||
|
"threadReadStateEffect": "MARK_UNREAD",
|
||||||
|
"timestamp": "1500000000000",
|
||||||
|
"unsendType": "deny_log_message",
|
||||||
|
},
|
||||||
|
"participants": ["1234", "2345", "3456"],
|
||||||
|
"requestContext": {"apiArgs": {}},
|
||||||
|
"tqSeqId": "1111",
|
||||||
|
"type": "change_thread_theme",
|
||||||
|
"untypedData": {
|
||||||
|
"should_show_icon": "1",
|
||||||
|
"theme_color": "FFFF7E29",
|
||||||
|
"accessibility_label": "Orange",
|
||||||
|
},
|
||||||
|
"class": "AdminTextMessage",
|
||||||
|
}
|
||||||
|
assert ColorSet(
|
||||||
|
author=User(session=session, id="1234"),
|
||||||
|
thread=Group(session=session, id="4321"),
|
||||||
|
color="#ff7e29",
|
||||||
|
at=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc),
|
||||||
|
) == parse_admin_message(session, data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_emoji_set(session):
|
||||||
|
data = {
|
||||||
|
"irisSeqId": "1111111",
|
||||||
|
"irisTags": ["DeltaAdminTextMessage"],
|
||||||
|
"messageMetadata": {
|
||||||
|
"actorFbId": "1234",
|
||||||
|
"adminText": "You set the emoji to 🌟.",
|
||||||
|
"folderId": {"systemFolderId": "INBOX"},
|
||||||
|
"messageId": "mid.$XYZ",
|
||||||
|
"offlineThreadingId": "11223344556677889900",
|
||||||
|
"skipBumpThread": False,
|
||||||
|
"skipSnippetUpdate": False,
|
||||||
|
"tags": ["source:generic_admin_text"],
|
||||||
|
"threadKey": {"otherUserFbId": "1234"},
|
||||||
|
"threadReadStateEffect": "MARK_UNREAD",
|
||||||
|
"timestamp": "1500000000000",
|
||||||
|
"unsendType": "deny_log_message",
|
||||||
|
},
|
||||||
|
"requestContext": {"apiArgs": {}},
|
||||||
|
"type": "change_thread_icon",
|
||||||
|
"untypedData": {
|
||||||
|
"thread_icon_url": "https://www.facebook.com/images/emoji.php/v9/te0/1/16/1f31f.png",
|
||||||
|
"thread_icon": "🌟",
|
||||||
|
},
|
||||||
|
"class": "AdminTextMessage",
|
||||||
|
}
|
||||||
|
assert EmojiSet(
|
||||||
|
author=User(session=session, id="1234"),
|
||||||
|
thread=User(session=session, id="1234"),
|
||||||
|
emoji="🌟",
|
||||||
|
at=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc),
|
||||||
|
) == parse_admin_message(session, data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_nickname_set(session):
|
||||||
|
data = {
|
||||||
|
"irisSeqId": "1111111",
|
||||||
|
"irisTags": ["DeltaAdminTextMessage", "is_from_iris_fanout"],
|
||||||
|
"messageMetadata": {
|
||||||
|
"actorFbId": "1234",
|
||||||
|
"adminText": "You set the nickname for Abc Def to abc.",
|
||||||
|
"folderId": {"systemFolderId": "INBOX"},
|
||||||
|
"messageId": "mid.$XYZ",
|
||||||
|
"offlineThreadingId": "11223344556677889900",
|
||||||
|
"skipBumpThread": False,
|
||||||
|
"tags": ["source:titan:web", "no_push"],
|
||||||
|
"threadKey": {"threadFbId": "4321"},
|
||||||
|
"threadReadStateEffect": "MARK_UNREAD",
|
||||||
|
"timestamp": "1500000000000",
|
||||||
|
"unsendType": "deny_log_message",
|
||||||
|
},
|
||||||
|
"participants": ["1234", "2345", "3456"],
|
||||||
|
"requestContext": {"apiArgs": {}},
|
||||||
|
"tqSeqId": "1111",
|
||||||
|
"type": "change_thread_nickname",
|
||||||
|
"untypedData": {"nickname": "abc", "participant_id": "2345"},
|
||||||
|
"class": "AdminTextMessage",
|
||||||
|
}
|
||||||
|
assert NicknameSet(
|
||||||
|
author=User(session=session, id="1234"),
|
||||||
|
thread=Group(session=session, id="4321"),
|
||||||
|
subject=User(session=session, id="2345"),
|
||||||
|
nickname="abc",
|
||||||
|
at=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc),
|
||||||
|
) == parse_admin_message(session, data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_nickname_clear(session):
|
||||||
|
data = {
|
||||||
|
"irisSeqId": "1111111",
|
||||||
|
"irisTags": ["DeltaAdminTextMessage"],
|
||||||
|
"messageMetadata": {
|
||||||
|
"actorFbId": "1234",
|
||||||
|
"adminText": "You cleared your nickname.",
|
||||||
|
"folderId": {"systemFolderId": "INBOX"},
|
||||||
|
"messageId": "mid.$XYZ",
|
||||||
|
"offlineThreadingId": "11223344556677889900",
|
||||||
|
"skipBumpThread": False,
|
||||||
|
"skipSnippetUpdate": False,
|
||||||
|
"tags": ["source:generic_admin_text"],
|
||||||
|
"threadKey": {"otherUserFbId": "1234"},
|
||||||
|
"threadReadStateEffect": "MARK_UNREAD",
|
||||||
|
"timestamp": "1500000000000",
|
||||||
|
"unsendType": "deny_log_message",
|
||||||
|
},
|
||||||
|
"requestContext": {"apiArgs": {}},
|
||||||
|
"type": "change_thread_nickname",
|
||||||
|
"untypedData": {"nickname": "", "participant_id": "1234"},
|
||||||
|
"class": "AdminTextMessage",
|
||||||
|
}
|
||||||
|
assert NicknameSet(
|
||||||
|
author=User(session=session, id="1234"),
|
||||||
|
thread=User(session=session, id="1234"),
|
||||||
|
subject=User(session=session, id="1234"),
|
||||||
|
nickname=None,
|
||||||
|
at=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc),
|
||||||
|
) == parse_admin_message(session, data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_admins_added(session):
|
||||||
|
data = {
|
||||||
|
"irisSeqId": "1111111",
|
||||||
|
"irisTags": ["DeltaAdminTextMessage", "is_from_iris_fanout"],
|
||||||
|
"messageMetadata": {
|
||||||
|
"actorFbId": "1234",
|
||||||
|
"adminText": "You added Abc Def as a group admin.",
|
||||||
|
"folderId": {"systemFolderId": "INBOX"},
|
||||||
|
"messageId": "mid.$XYZ",
|
||||||
|
"offlineThreadingId": "11223344556677889900",
|
||||||
|
"skipBumpThread": True,
|
||||||
|
"tags": ["source:titan:web"],
|
||||||
|
"threadKey": {"threadFbId": "4321"},
|
||||||
|
"threadReadStateEffect": "KEEP_AS_IS",
|
||||||
|
"timestamp": "1500000000000",
|
||||||
|
"unsendType": "deny_log_message",
|
||||||
|
},
|
||||||
|
"participants": ["1234", "2345", "3456"],
|
||||||
|
"requestContext": {"apiArgs": {}},
|
||||||
|
"tqSeqId": "1111",
|
||||||
|
"type": "change_thread_admins",
|
||||||
|
"untypedData": {
|
||||||
|
"THREAD_CATEGORY": "GROUP",
|
||||||
|
"TARGET_ID": "2345",
|
||||||
|
"ADMIN_TYPE": "0",
|
||||||
|
"ADMIN_EVENT": "add_admin",
|
||||||
|
},
|
||||||
|
"class": "AdminTextMessage",
|
||||||
|
}
|
||||||
|
assert AdminsAdded(
|
||||||
|
author=User(session=session, id="1234"),
|
||||||
|
thread=Group(session=session, id="4321"),
|
||||||
|
added=[User(session=session, id="2345")],
|
||||||
|
at=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc),
|
||||||
|
) == parse_admin_message(session, data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_admins_removed(session):
|
||||||
|
data = {
|
||||||
|
"irisSeqId": "1111111",
|
||||||
|
"irisTags": ["DeltaAdminTextMessage", "is_from_iris_fanout"],
|
||||||
|
"messageMetadata": {
|
||||||
|
"actorFbId": "1234",
|
||||||
|
"adminText": "You removed yourself as a group admin.",
|
||||||
|
"folderId": {"systemFolderId": "INBOX"},
|
||||||
|
"messageId": "mid.$XYZ",
|
||||||
|
"offlineThreadingId": "11223344556677889900",
|
||||||
|
"skipBumpThread": True,
|
||||||
|
"tags": ["source:titan:web"],
|
||||||
|
"threadKey": {"threadFbId": "4321"},
|
||||||
|
"threadReadStateEffect": "KEEP_AS_IS",
|
||||||
|
"timestamp": "1500000000000",
|
||||||
|
"unsendType": "deny_log_message",
|
||||||
|
},
|
||||||
|
"participants": ["1234", "2345", "3456"],
|
||||||
|
"requestContext": {"apiArgs": {}},
|
||||||
|
"tqSeqId": "1111",
|
||||||
|
"type": "change_thread_admins",
|
||||||
|
"untypedData": {
|
||||||
|
"THREAD_CATEGORY": "GROUP",
|
||||||
|
"TARGET_ID": "1234",
|
||||||
|
"ADMIN_TYPE": "0",
|
||||||
|
"ADMIN_EVENT": "remove_admin",
|
||||||
|
},
|
||||||
|
"class": "AdminTextMessage",
|
||||||
|
}
|
||||||
|
assert AdminsRemoved(
|
||||||
|
author=User(session=session, id="1234"),
|
||||||
|
thread=Group(session=session, id="4321"),
|
||||||
|
removed=[User(session=session, id="1234")],
|
||||||
|
at=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc),
|
||||||
|
) == parse_admin_message(session, data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_approvalmode_set(session):
|
||||||
|
data = {
|
||||||
|
"irisSeqId": "1111111",
|
||||||
|
"irisTags": ["DeltaAdminTextMessage", "is_from_iris_fanout"],
|
||||||
|
"messageMetadata": {
|
||||||
|
"actorFbId": "1234",
|
||||||
|
"adminText": "You turned on member approval and will review requests to join the group.",
|
||||||
|
"folderId": {"systemFolderId": "INBOX"},
|
||||||
|
"messageId": "mid.$XYZ",
|
||||||
|
"offlineThreadingId": "11223344556677889900",
|
||||||
|
"skipBumpThread": True,
|
||||||
|
"tags": ["source:titan:web", "no_push"],
|
||||||
|
"threadKey": {"threadFbId": "4321"},
|
||||||
|
"threadReadStateEffect": "KEEP_AS_IS",
|
||||||
|
"timestamp": "1500000000000",
|
||||||
|
"unsendType": "deny_log_message",
|
||||||
|
},
|
||||||
|
"participants": ["1234", "2345", "3456"],
|
||||||
|
"requestContext": {"apiArgs": {}},
|
||||||
|
"tqSeqId": "1111",
|
||||||
|
"type": "change_thread_approval_mode",
|
||||||
|
"untypedData": {"APPROVAL_MODE": "1", "THREAD_CATEGORY": "GROUP"},
|
||||||
|
"class": "AdminTextMessage",
|
||||||
|
}
|
||||||
|
assert ApprovalModeSet(
|
||||||
|
author=User(session=session, id="1234"),
|
||||||
|
thread=Group(session=session, id="4321"),
|
||||||
|
require_admin_approval=True,
|
||||||
|
at=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc),
|
||||||
|
) == parse_admin_message(session, data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_approvalmode_unset(session):
|
||||||
|
data = {
|
||||||
|
"irisSeqId": "1111111",
|
||||||
|
"irisTags": ["DeltaAdminTextMessage", "is_from_iris_fanout"],
|
||||||
|
"messageMetadata": {
|
||||||
|
"actorFbId": "1234",
|
||||||
|
"adminText": "You turned off member approval. Anyone with the link can join the group.",
|
||||||
|
"folderId": {"systemFolderId": "INBOX"},
|
||||||
|
"messageId": "mid.$XYZ",
|
||||||
|
"offlineThreadingId": "11223344556677889900",
|
||||||
|
"skipBumpThread": True,
|
||||||
|
"tags": ["source:titan:web", "no_push"],
|
||||||
|
"threadKey": {"threadFbId": "4321"},
|
||||||
|
"threadReadStateEffect": "KEEP_AS_IS",
|
||||||
|
"timestamp": "1500000000000",
|
||||||
|
"unsendType": "deny_log_message",
|
||||||
|
},
|
||||||
|
"participants": ["1234", "2345", "3456"],
|
||||||
|
"requestContext": {"apiArgs": {}},
|
||||||
|
"tqSeqId": "1111",
|
||||||
|
"type": "change_thread_approval_mode",
|
||||||
|
"untypedData": {"APPROVAL_MODE": "0", "THREAD_CATEGORY": "GROUP"},
|
||||||
|
"class": "AdminTextMessage",
|
||||||
|
}
|
||||||
|
assert ApprovalModeSet(
|
||||||
|
author=User(session=session, id="1234"),
|
||||||
|
thread=Group(session=session, id="4321"),
|
||||||
|
require_admin_approval=False,
|
||||||
|
at=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc),
|
||||||
|
) == parse_admin_message(session, data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_call_started(session):
|
||||||
|
data = {
|
||||||
|
"irisSeqId": "1111111",
|
||||||
|
"irisTags": ["DeltaAdminTextMessage", "is_from_iris_fanout"],
|
||||||
|
"messageMetadata": {
|
||||||
|
"actorFbId": "1234",
|
||||||
|
"adminText": "You started a call.",
|
||||||
|
"folderId": {"systemFolderId": "INBOX"},
|
||||||
|
"messageId": "mid.$XYZ",
|
||||||
|
"offlineThreadingId": "11223344556677889900",
|
||||||
|
"skipBumpThread": False,
|
||||||
|
"tags": ["source:titan:web", "no_push"],
|
||||||
|
"threadKey": {"threadFbId": "4321"},
|
||||||
|
"threadReadStateEffect": "MARK_UNREAD",
|
||||||
|
"timestamp": "1500000000000",
|
||||||
|
"unsendType": "deny_log_message",
|
||||||
|
},
|
||||||
|
"participants": ["1234", "2345", "3456"],
|
||||||
|
"requestContext": {"apiArgs": {}},
|
||||||
|
"tqSeqId": "1111",
|
||||||
|
"type": "messenger_call_log",
|
||||||
|
"untypedData": {
|
||||||
|
"call_capture_attachments": "",
|
||||||
|
"caller_id": "1234",
|
||||||
|
"conference_name": "MESSENGER:134845267536444",
|
||||||
|
"rating": "",
|
||||||
|
"messenger_call_instance_id": "0",
|
||||||
|
"video": "",
|
||||||
|
"event": "group_call_started",
|
||||||
|
"server_info": "XYZ123ABC",
|
||||||
|
"call_duration": "0",
|
||||||
|
"callee_id": "0",
|
||||||
|
},
|
||||||
|
"class": "AdminTextMessage",
|
||||||
|
}
|
||||||
|
data2 = {
|
||||||
|
"callState": "AUDIO_GROUP_CALL",
|
||||||
|
"messageMetadata": {
|
||||||
|
"actorFbId": "1234",
|
||||||
|
"folderId": {"systemFolderId": "INBOX"},
|
||||||
|
"messageId": "mid.$XYZ",
|
||||||
|
"offlineThreadingId": "11223344556677889900",
|
||||||
|
"skipBumpThread": False,
|
||||||
|
"tags": [],
|
||||||
|
"threadKey": {"threadFbId": "4321"},
|
||||||
|
"threadReadStateEffect": "KEEP_AS_IS",
|
||||||
|
"timestamp": "1500000000000",
|
||||||
|
},
|
||||||
|
"serverInfoData": "XYZ123ABC",
|
||||||
|
"class": "RtcCallData",
|
||||||
|
}
|
||||||
|
assert CallStarted(
|
||||||
|
author=User(session=session, id="1234"),
|
||||||
|
thread=Group(session=session, id="4321"),
|
||||||
|
at=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc),
|
||||||
|
) == parse_admin_message(session, data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_group_call_ended(session):
|
||||||
|
data = {
|
||||||
|
"irisSeqId": "1111111",
|
||||||
|
"irisTags": ["DeltaAdminTextMessage", "is_from_iris_fanout"],
|
||||||
|
"messageMetadata": {
|
||||||
|
"actorFbId": "1234",
|
||||||
|
"adminText": "The call ended.",
|
||||||
|
"folderId": {"systemFolderId": "INBOX"},
|
||||||
|
"messageId": "mid.$XYZ",
|
||||||
|
"offlineThreadingId": "11223344556677889900",
|
||||||
|
"skipBumpThread": False,
|
||||||
|
"tags": ["source:titan:web", "no_push"],
|
||||||
|
"threadKey": {"threadFbId": "4321"},
|
||||||
|
"threadReadStateEffect": "MARK_UNREAD",
|
||||||
|
"timestamp": "1500000000000",
|
||||||
|
"unsendType": "deny_log_message",
|
||||||
|
},
|
||||||
|
"participants": ["1234", "2345", "3456"],
|
||||||
|
"requestContext": {"apiArgs": {}},
|
||||||
|
"tqSeqId": "1111",
|
||||||
|
"type": "messenger_call_log",
|
||||||
|
"untypedData": {
|
||||||
|
"call_capture_attachments": "",
|
||||||
|
"caller_id": "1234",
|
||||||
|
"conference_name": "MESSENGER:1234567890",
|
||||||
|
"rating": "0",
|
||||||
|
"messenger_call_instance_id": "1234567890",
|
||||||
|
"video": "",
|
||||||
|
"event": "group_call_ended",
|
||||||
|
"server_info": "XYZ123ABC",
|
||||||
|
"call_duration": "31",
|
||||||
|
"callee_id": "0",
|
||||||
|
},
|
||||||
|
"class": "AdminTextMessage",
|
||||||
|
}
|
||||||
|
data2 = {
|
||||||
|
"callState": "NO_ONGOING_CALL",
|
||||||
|
"messageMetadata": {
|
||||||
|
"actorFbId": "1234",
|
||||||
|
"folderId": {"systemFolderId": "INBOX"},
|
||||||
|
"messageId": "mid.$XYZ",
|
||||||
|
"offlineThreadingId": "11223344556677889900",
|
||||||
|
"skipBumpThread": False,
|
||||||
|
"tags": [],
|
||||||
|
"threadKey": {"threadFbId": "4321"},
|
||||||
|
"threadReadStateEffect": "KEEP_AS_IS",
|
||||||
|
"timestamp": "1500000000000",
|
||||||
|
},
|
||||||
|
"class": "RtcCallData",
|
||||||
|
}
|
||||||
|
assert CallEnded(
|
||||||
|
author=User(session=session, id="1234"),
|
||||||
|
thread=Group(session=session, id="4321"),
|
||||||
|
duration=datetime.timedelta(seconds=31),
|
||||||
|
at=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc),
|
||||||
|
) == parse_admin_message(session, data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_call_ended(session):
|
||||||
|
data = {
|
||||||
|
"irisSeqId": "1111111",
|
||||||
|
"irisTags": ["DeltaAdminTextMessage", "is_from_iris_fanout"],
|
||||||
|
"messageMetadata": {
|
||||||
|
"actorFbId": "1234",
|
||||||
|
"adminText": "Abc called you.",
|
||||||
|
"folderId": {"systemFolderId": "INBOX"},
|
||||||
|
"messageId": "mid.$XYZ",
|
||||||
|
"offlineThreadingId": "11223344556677889900",
|
||||||
|
"skipBumpThread": False,
|
||||||
|
"skipSnippetUpdate": False,
|
||||||
|
"tags": ["source:generic_admin_text", "no_push"],
|
||||||
|
"threadKey": {"otherUserFbId": "1234"},
|
||||||
|
"threadReadStateEffect": "KEEP_AS_IS",
|
||||||
|
"timestamp": "1500000000000",
|
||||||
|
"unsendType": "deny_log_message",
|
||||||
|
},
|
||||||
|
"requestContext": {"apiArgs": {}},
|
||||||
|
"type": "messenger_call_log",
|
||||||
|
"untypedData": {
|
||||||
|
"call_capture_attachments": "",
|
||||||
|
"caller_id": "1234",
|
||||||
|
"conference_name": "MESSENGER:1234567890",
|
||||||
|
"rating": "0",
|
||||||
|
"messenger_call_instance_id": "1234567890",
|
||||||
|
"video": "",
|
||||||
|
"event": "one_on_one_call_ended",
|
||||||
|
"server_info": "",
|
||||||
|
"call_duration": "3",
|
||||||
|
"callee_id": "100002950119740",
|
||||||
|
},
|
||||||
|
"class": "AdminTextMessage",
|
||||||
|
}
|
||||||
|
assert CallEnded(
|
||||||
|
author=User(session=session, id="1234"),
|
||||||
|
thread=User(session=session, id="1234"),
|
||||||
|
duration=datetime.timedelta(seconds=3),
|
||||||
|
at=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc),
|
||||||
|
) == parse_admin_message(session, data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_call_joined(session):
|
||||||
|
data = {
|
||||||
|
"irisSeqId": "1111111",
|
||||||
|
"irisTags": ["DeltaAdminTextMessage", "is_from_iris_fanout"],
|
||||||
|
"messageMetadata": {
|
||||||
|
"actorFbId": "1234",
|
||||||
|
"adminText": "Abc joined the call.",
|
||||||
|
"folderId": {"systemFolderId": "INBOX"},
|
||||||
|
"messageId": "mid.$XYZ",
|
||||||
|
"offlineThreadingId": "11223344556677889900",
|
||||||
|
"skipBumpThread": False,
|
||||||
|
"tags": ["source:titan:web"],
|
||||||
|
"threadKey": {"threadFbId": "4321"},
|
||||||
|
"threadReadStateEffect": "MARK_UNREAD",
|
||||||
|
"timestamp": "1500000000000",
|
||||||
|
"unsendType": "deny_log_message",
|
||||||
|
},
|
||||||
|
"participants": ["1234", "2345", "3456"],
|
||||||
|
"requestContext": {"apiArgs": {}},
|
||||||
|
"tqSeqId": "1111",
|
||||||
|
"type": "participant_joined_group_call",
|
||||||
|
"untypedData": {
|
||||||
|
"server_info_data": "XYZ123ABC",
|
||||||
|
"group_call_type": "0",
|
||||||
|
"joining_user": "2345",
|
||||||
|
},
|
||||||
|
"class": "AdminTextMessage",
|
||||||
|
}
|
||||||
|
assert CallJoined(
|
||||||
|
author=User(session=session, id="1234"),
|
||||||
|
thread=Group(session=session, id="4321"),
|
||||||
|
at=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc),
|
||||||
|
) == parse_admin_message(session, data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_poll_created(session):
|
||||||
|
poll_data = {
|
||||||
|
"id": "112233",
|
||||||
|
"text": "A poll",
|
||||||
|
"total_count": 2,
|
||||||
|
"viewer_has_voted": "true",
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"id": "1001",
|
||||||
|
"text": "Option A",
|
||||||
|
"total_count": 1,
|
||||||
|
"viewer_has_voted": "true",
|
||||||
|
"voters": ["1234"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "1002",
|
||||||
|
"text": "Option B",
|
||||||
|
"total_count": 0,
|
||||||
|
"viewer_has_voted": "false",
|
||||||
|
"voters": [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
data = {
|
||||||
|
"irisSeqId": "1111111",
|
||||||
|
"irisTags": ["DeltaAdminTextMessage", "is_from_iris_fanout"],
|
||||||
|
"messageMetadata": {
|
||||||
|
"actorFbId": "1234",
|
||||||
|
"adminText": "You created a poll: A poll.",
|
||||||
|
"folderId": {"systemFolderId": "INBOX"},
|
||||||
|
"messageId": "mid.$XYZ",
|
||||||
|
"offlineThreadingId": "11223344556677889900",
|
||||||
|
"skipBumpThread": False,
|
||||||
|
"tags": ["source:titan:web"],
|
||||||
|
"threadKey": {"threadFbId": "4321"},
|
||||||
|
"threadReadStateEffect": "MARK_UNREAD",
|
||||||
|
"timestamp": "1500000000000",
|
||||||
|
"unsendType": "deny_log_message",
|
||||||
|
},
|
||||||
|
"participants": ["1234", "2345", "3456"],
|
||||||
|
"requestContext": {"apiArgs": {}},
|
||||||
|
"tqSeqId": "1111",
|
||||||
|
"type": "group_poll",
|
||||||
|
"untypedData": {
|
||||||
|
"added_option_ids": "[]",
|
||||||
|
"removed_option_ids": "[]",
|
||||||
|
"question_json": _util.json_minimal(poll_data),
|
||||||
|
"event_type": "question_creation",
|
||||||
|
"question_id": "112233",
|
||||||
|
},
|
||||||
|
"class": "AdminTextMessage",
|
||||||
|
}
|
||||||
|
assert PollCreated(
|
||||||
|
author=User(session=session, id="1234"),
|
||||||
|
thread=Group(session=session, id="4321"),
|
||||||
|
poll=Poll(
|
||||||
|
session=session,
|
||||||
|
id="112233",
|
||||||
|
question="A poll",
|
||||||
|
options=[
|
||||||
|
PollOption(
|
||||||
|
id="1001",
|
||||||
|
text="Option A",
|
||||||
|
vote=True,
|
||||||
|
voters=["1234"],
|
||||||
|
votes_count=1,
|
||||||
|
),
|
||||||
|
PollOption(
|
||||||
|
id="1002", text="Option B", vote=False, voters=[], votes_count=0
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options_count=2,
|
||||||
|
),
|
||||||
|
at=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc),
|
||||||
|
) == parse_admin_message(session, data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_poll_answered(session):
|
||||||
|
poll_data = {
|
||||||
|
"id": "112233",
|
||||||
|
"text": "A poll",
|
||||||
|
"total_count": 3,
|
||||||
|
"viewer_has_voted": "true",
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"id": "1002",
|
||||||
|
"text": "Option B",
|
||||||
|
"total_count": 2,
|
||||||
|
"viewer_has_voted": "true",
|
||||||
|
"voters": ["1234", "2345"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "1003",
|
||||||
|
"text": "Option C",
|
||||||
|
"total_count": 1,
|
||||||
|
"viewer_has_voted": "true",
|
||||||
|
"voters": ["1234"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "1001",
|
||||||
|
"text": "Option A",
|
||||||
|
"total_count": 0,
|
||||||
|
"viewer_has_voted": "false",
|
||||||
|
"voters": [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
data = {
|
||||||
|
"irisSeqId": "1111111",
|
||||||
|
"irisTags": ["DeltaAdminTextMessage", "is_from_iris_fanout"],
|
||||||
|
"messageMetadata": {
|
||||||
|
"actorFbId": "1234",
|
||||||
|
"adminText": 'You changed your vote to "Option B" and 1 other option in the poll: A poll.',
|
||||||
|
"folderId": {"systemFolderId": "INBOX"},
|
||||||
|
"messageId": "mid.$XYZ",
|
||||||
|
"offlineThreadingId": "11223344556677889900",
|
||||||
|
"skipBumpThread": False,
|
||||||
|
"tags": ["source:titan:web"],
|
||||||
|
"threadKey": {"threadFbId": "4321"},
|
||||||
|
"threadReadStateEffect": "MARK_UNREAD",
|
||||||
|
"timestamp": "1500000000000",
|
||||||
|
"unsendType": "deny_log_message",
|
||||||
|
},
|
||||||
|
"participants": ["1234", "2345", "3456"],
|
||||||
|
"requestContext": {"apiArgs": {}},
|
||||||
|
"tqSeqId": "1111",
|
||||||
|
"type": "group_poll",
|
||||||
|
"untypedData": {
|
||||||
|
"added_option_ids": "[1002,1003]",
|
||||||
|
"removed_option_ids": "[1001]",
|
||||||
|
"question_json": _util.json_minimal(poll_data),
|
||||||
|
"event_type": "update_vote",
|
||||||
|
"question_id": "112233",
|
||||||
|
},
|
||||||
|
"class": "AdminTextMessage",
|
||||||
|
}
|
||||||
|
assert PollVoted(
|
||||||
|
author=User(session=session, id="1234"),
|
||||||
|
thread=Group(session=session, id="4321"),
|
||||||
|
poll=Poll(
|
||||||
|
session=session,
|
||||||
|
id="112233",
|
||||||
|
question="A poll",
|
||||||
|
options=[
|
||||||
|
PollOption(
|
||||||
|
id="1002",
|
||||||
|
text="Option B",
|
||||||
|
vote=True,
|
||||||
|
voters=["1234", "2345"],
|
||||||
|
votes_count=2,
|
||||||
|
),
|
||||||
|
PollOption(
|
||||||
|
id="1003",
|
||||||
|
text="Option C",
|
||||||
|
vote=True,
|
||||||
|
voters=["1234"],
|
||||||
|
votes_count=1,
|
||||||
|
),
|
||||||
|
PollOption(
|
||||||
|
id="1001", text="Option A", vote=False, voters=[], votes_count=0
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options_count=3,
|
||||||
|
),
|
||||||
|
added_ids=["1002", "1003"],
|
||||||
|
removed_ids=["1001"],
|
||||||
|
at=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc),
|
||||||
|
) == parse_admin_message(session, data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_plan_created(session):
|
||||||
|
guest_list = [
|
||||||
|
{"guest_list_state": "INVITED", "node": {"id": "3456"}},
|
||||||
|
{"guest_list_state": "INVITED", "node": {"id": "2345"}},
|
||||||
|
{"guest_list_state": "GOING", "node": {"id": "1234"}},
|
||||||
|
]
|
||||||
|
data = {
|
||||||
|
"irisSeqId": "1111111",
|
||||||
|
"irisTags": ["DeltaAdminTextMessage", "is_from_iris_fanout"],
|
||||||
|
"messageMetadata": {
|
||||||
|
"actorFbId": "1234",
|
||||||
|
"adminText": "You created a plan.",
|
||||||
|
"folderId": {"systemFolderId": "INBOX"},
|
||||||
|
"messageId": "mid.$XYZ",
|
||||||
|
"offlineThreadingId": "11223344556677889900",
|
||||||
|
"skipBumpThread": False,
|
||||||
|
"tags": ["source:titan:web", "no_push"],
|
||||||
|
"threadKey": {"threadFbId": "4321"},
|
||||||
|
"threadReadStateEffect": "MARK_UNREAD",
|
||||||
|
"timestamp": "1500000000000",
|
||||||
|
"unsendType": "deny_log_message",
|
||||||
|
},
|
||||||
|
"participants": ["1234", "2345", "3456"],
|
||||||
|
"requestContext": {"apiArgs": {}},
|
||||||
|
"tqSeqId": "1111",
|
||||||
|
"type": "lightweight_event_create",
|
||||||
|
"untypedData": {
|
||||||
|
"event_timezone": "",
|
||||||
|
"event_creator_id": "1234",
|
||||||
|
"event_id": "112233",
|
||||||
|
"event_type": "EVENT",
|
||||||
|
"event_track_rsvp": "1",
|
||||||
|
"event_title": "A plan",
|
||||||
|
"event_time": "1600000000",
|
||||||
|
"event_seconds_to_notify_before": "3600",
|
||||||
|
"guest_state_list": _util.json_minimal(guest_list),
|
||||||
|
},
|
||||||
|
"class": "AdminTextMessage",
|
||||||
|
}
|
||||||
|
assert PlanCreated(
|
||||||
|
author=User(session=session, id="1234"),
|
||||||
|
thread=Group(session=session, id="4321"),
|
||||||
|
plan=PlanData(
|
||||||
|
session=session,
|
||||||
|
id="112233",
|
||||||
|
time=datetime.datetime(
|
||||||
|
2020, 9, 13, 12, 26, 40, tzinfo=datetime.timezone.utc
|
||||||
|
),
|
||||||
|
title="A plan",
|
||||||
|
author_id="1234",
|
||||||
|
guests={
|
||||||
|
"1234": GuestStatus.GOING,
|
||||||
|
"2345": GuestStatus.INVITED,
|
||||||
|
"3456": GuestStatus.INVITED,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
at=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc),
|
||||||
|
) == parse_admin_message(session, data)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skip(reason="Need to gather test data")
|
||||||
|
def test_plan_ended(session):
|
||||||
|
data = {}
|
||||||
|
assert PlanEnded(
|
||||||
|
author=User(session=session, id="1234"),
|
||||||
|
thread=Group(session=session, id="4321"),
|
||||||
|
plan=PlanData(
|
||||||
|
session=session,
|
||||||
|
id="112233",
|
||||||
|
time=datetime.datetime(
|
||||||
|
2020, 9, 13, 12, 26, 40, tzinfo=datetime.timezone.utc
|
||||||
|
),
|
||||||
|
title="A plan",
|
||||||
|
author_id="1234",
|
||||||
|
guests={
|
||||||
|
"1234": GuestStatus.GOING,
|
||||||
|
"2345": GuestStatus.INVITED,
|
||||||
|
"3456": GuestStatus.INVITED,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
at=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc),
|
||||||
|
) == parse_admin_message(session, data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_plan_edited(session):
|
||||||
|
guest_list = [
|
||||||
|
{"guest_list_state": "INVITED", "node": {"id": "3456"}},
|
||||||
|
{"guest_list_state": "INVITED", "node": {"id": "2345"}},
|
||||||
|
{"guest_list_state": "GOING", "node": {"id": "1234"}},
|
||||||
|
]
|
||||||
|
data = {
|
||||||
|
"irisSeqId": "1111111",
|
||||||
|
"irisTags": ["DeltaAdminTextMessage", "is_from_iris_fanout"],
|
||||||
|
"messageMetadata": {
|
||||||
|
"actorFbId": "1234",
|
||||||
|
"adminText": "You named the plan A plan.",
|
||||||
|
"folderId": {"systemFolderId": "INBOX"},
|
||||||
|
"messageId": "mid.$XYZ",
|
||||||
|
"offlineThreadingId": "11223344556677889900",
|
||||||
|
"skipBumpThread": False,
|
||||||
|
"tags": ["source:titan:web", "no_push"],
|
||||||
|
"threadKey": {"threadFbId": "4321"},
|
||||||
|
"threadReadStateEffect": "MARK_UNREAD",
|
||||||
|
"timestamp": "1500000000000",
|
||||||
|
"unsendType": "deny_log_message",
|
||||||
|
},
|
||||||
|
"participants": ["1234", "2345", "3456"],
|
||||||
|
"requestContext": {"apiArgs": {}},
|
||||||
|
"tqSeqId": "1111",
|
||||||
|
"type": "lightweight_event_update",
|
||||||
|
"untypedData": {
|
||||||
|
"event_creator_id": "1234",
|
||||||
|
"latitude": "0",
|
||||||
|
"event_title": "A plan",
|
||||||
|
"event_seconds_to_notify_before": "3600",
|
||||||
|
"guest_state_list": _util.json_minimal(guest_list),
|
||||||
|
"event_end_time": "0",
|
||||||
|
"event_timezone": "",
|
||||||
|
"event_id": "112233",
|
||||||
|
"event_type": "EVENT",
|
||||||
|
"event_location_id": "2233445566",
|
||||||
|
"event_location_name": "",
|
||||||
|
"event_time": "1600000000",
|
||||||
|
"event_note": "",
|
||||||
|
"longitude": "0",
|
||||||
|
},
|
||||||
|
"class": "AdminTextMessage",
|
||||||
|
}
|
||||||
|
assert PlanEdited(
|
||||||
|
author=User(session=session, id="1234"),
|
||||||
|
thread=Group(session=session, id="4321"),
|
||||||
|
plan=PlanData(
|
||||||
|
session=session,
|
||||||
|
id="112233",
|
||||||
|
time=datetime.datetime(
|
||||||
|
2020, 9, 13, 12, 26, 40, tzinfo=datetime.timezone.utc
|
||||||
|
),
|
||||||
|
title="A plan",
|
||||||
|
location_id="2233445566",
|
||||||
|
author_id="1234",
|
||||||
|
guests={
|
||||||
|
"1234": GuestStatus.GOING,
|
||||||
|
"2345": GuestStatus.INVITED,
|
||||||
|
"3456": GuestStatus.INVITED,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
at=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc),
|
||||||
|
) == parse_admin_message(session, data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_plan_deleted(session):
|
||||||
|
guest_list = [
|
||||||
|
{"guest_list_state": "GOING", "node": {"id": "1234"}},
|
||||||
|
{"guest_list_state": "INVITED", "node": {"id": "3456"}},
|
||||||
|
{"guest_list_state": "INVITED", "node": {"id": "2345"}},
|
||||||
|
]
|
||||||
|
data = {
|
||||||
|
"irisSeqId": "1111111",
|
||||||
|
"irisTags": ["DeltaAdminTextMessage", "is_from_iris_fanout"],
|
||||||
|
"messageMetadata": {
|
||||||
|
"actorFbId": "1234",
|
||||||
|
"adminText": "You deleted the plan A plan for Mon, 20 Jan at 15:30.",
|
||||||
|
"folderId": {"systemFolderId": "INBOX"},
|
||||||
|
"messageId": "mid.$XYZ",
|
||||||
|
"offlineThreadingId": "11223344556677889900",
|
||||||
|
"skipBumpThread": False,
|
||||||
|
"tags": ["source:titan:web", "no_push"],
|
||||||
|
"threadKey": {"threadFbId": "4321"},
|
||||||
|
"threadReadStateEffect": "MARK_UNREAD",
|
||||||
|
"timestamp": "1500000000000",
|
||||||
|
"unsendType": "deny_log_message",
|
||||||
|
},
|
||||||
|
"participants": ["1234", "2345", "3456"],
|
||||||
|
"requestContext": {"apiArgs": {}},
|
||||||
|
"tqSeqId": "1111",
|
||||||
|
"type": "lightweight_event_delete",
|
||||||
|
"untypedData": {
|
||||||
|
"event_end_time": "0",
|
||||||
|
"event_timezone": "",
|
||||||
|
"event_id": "112233",
|
||||||
|
"event_type": "EVENT",
|
||||||
|
"event_location_id": "2233445566",
|
||||||
|
"latitude": "0",
|
||||||
|
"event_title": "A plan",
|
||||||
|
"event_time": "1600000000",
|
||||||
|
"event_seconds_to_notify_before": "3600",
|
||||||
|
"guest_state_list": _util.json_minimal(guest_list),
|
||||||
|
"event_note": "",
|
||||||
|
"longitude": "0",
|
||||||
|
},
|
||||||
|
"class": "AdminTextMessage",
|
||||||
|
}
|
||||||
|
assert PlanDeleted(
|
||||||
|
author=User(session=session, id="1234"),
|
||||||
|
thread=Group(session=session, id="4321"),
|
||||||
|
plan=PlanData(
|
||||||
|
session=session,
|
||||||
|
id="112233",
|
||||||
|
time=datetime.datetime(
|
||||||
|
2020, 9, 13, 12, 26, 40, tzinfo=datetime.timezone.utc
|
||||||
|
),
|
||||||
|
title="A plan",
|
||||||
|
location_id="2233445566",
|
||||||
|
author_id=None,
|
||||||
|
guests={
|
||||||
|
"1234": GuestStatus.GOING,
|
||||||
|
"2345": GuestStatus.INVITED,
|
||||||
|
"3456": GuestStatus.INVITED,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
at=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc),
|
||||||
|
) == parse_admin_message(session, data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_plan_participation(session):
|
||||||
|
guest_list = [
|
||||||
|
{"guest_list_state": "DECLINED", "node": {"id": "1234"}},
|
||||||
|
{"guest_list_state": "GOING", "node": {"id": "2345"}},
|
||||||
|
{"guest_list_state": "INVITED", "node": {"id": "3456"}},
|
||||||
|
]
|
||||||
|
data = {
|
||||||
|
"irisSeqId": "1111111",
|
||||||
|
"irisTags": ["DeltaAdminTextMessage", "is_from_iris_fanout"],
|
||||||
|
"messageMetadata": {
|
||||||
|
"actorFbId": "1234",
|
||||||
|
"adminText": "You responded Can't Go to def.",
|
||||||
|
"folderId": {"systemFolderId": "INBOX"},
|
||||||
|
"messageId": "mid.$XYZ",
|
||||||
|
"offlineThreadingId": "11223344556677889900",
|
||||||
|
"skipBumpThread": False,
|
||||||
|
"tags": ["source:titan:web", "no_push"],
|
||||||
|
"threadKey": {"threadFbId": "4321"},
|
||||||
|
"threadReadStateEffect": "MARK_UNREAD",
|
||||||
|
"timestamp": "1500000000000",
|
||||||
|
"unsendType": "deny_log_message",
|
||||||
|
},
|
||||||
|
"participants": ["1234", "2345", "3456"],
|
||||||
|
"requestContext": {"apiArgs": {}},
|
||||||
|
"tqSeqId": "1111",
|
||||||
|
"type": "lightweight_event_rsvp",
|
||||||
|
"untypedData": {
|
||||||
|
"event_creator_id": "2345",
|
||||||
|
"guest_status": "DECLINED",
|
||||||
|
"latitude": "0",
|
||||||
|
"event_track_rsvp": "1",
|
||||||
|
"event_title": "A plan",
|
||||||
|
"event_seconds_to_notify_before": "3600",
|
||||||
|
"guest_state_list": _util.json_minimal(guest_list),
|
||||||
|
"event_end_time": "0",
|
||||||
|
"event_timezone": "",
|
||||||
|
"event_id": "112233",
|
||||||
|
"event_type": "EVENT",
|
||||||
|
"guest_id": "1234",
|
||||||
|
"event_location_id": "2233445566",
|
||||||
|
"event_time": "1600000000",
|
||||||
|
"event_note": "",
|
||||||
|
"longitude": "0",
|
||||||
|
},
|
||||||
|
"class": "AdminTextMessage",
|
||||||
|
}
|
||||||
|
assert PlanResponded(
|
||||||
|
author=User(session=session, id="1234"),
|
||||||
|
thread=Group(session=session, id="4321"),
|
||||||
|
plan=PlanData(
|
||||||
|
session=session,
|
||||||
|
id="112233",
|
||||||
|
time=datetime.datetime(
|
||||||
|
2020, 9, 13, 12, 26, 40, tzinfo=datetime.timezone.utc
|
||||||
|
),
|
||||||
|
title="A plan",
|
||||||
|
location_id="2233445566",
|
||||||
|
author_id="2345",
|
||||||
|
guests={
|
||||||
|
"1234": GuestStatus.DECLINED,
|
||||||
|
"2345": GuestStatus.GOING,
|
||||||
|
"3456": GuestStatus.INVITED,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
take_part=False,
|
||||||
|
at=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc),
|
||||||
|
) == parse_admin_message(session, data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_admin_message_unknown(session):
|
||||||
|
data = {"class": "AdminTextMessage", "type": "abc"}
|
||||||
|
assert UnknownEvent(source="Delta type", data=data) == parse_admin_message(
|
||||||
|
session, data
|
||||||
|
)
|
||||||
137
tests/events/test_main.py
Normal file
137
tests/events/test_main.py
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import datetime
|
||||||
|
from fbchat import (
|
||||||
|
_util,
|
||||||
|
User,
|
||||||
|
Group,
|
||||||
|
Message,
|
||||||
|
ParseError,
|
||||||
|
UnknownEvent,
|
||||||
|
Typing,
|
||||||
|
FriendRequest,
|
||||||
|
Presence,
|
||||||
|
ReactionEvent,
|
||||||
|
UnfetchedThreadEvent,
|
||||||
|
ActiveStatus,
|
||||||
|
)
|
||||||
|
from fbchat._events import parse_events
|
||||||
|
|
||||||
|
|
||||||
|
def test_t_ms_full(session):
|
||||||
|
"""A full example of parsing of data in /t_ms."""
|
||||||
|
payload = {
|
||||||
|
"deltas": [
|
||||||
|
{
|
||||||
|
"deltaMessageReaction": {
|
||||||
|
"threadKey": {"threadFbId": 4321},
|
||||||
|
"messageId": "mid.$XYZ",
|
||||||
|
"action": 0,
|
||||||
|
"userId": 1234,
|
||||||
|
"reaction": "😢",
|
||||||
|
"senderId": 1234,
|
||||||
|
"offlineThreadingId": "1122334455",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
data = {
|
||||||
|
"deltas": [
|
||||||
|
{
|
||||||
|
"payload": [ord(x) for x in _util.json_minimal(payload)],
|
||||||
|
"class": "ClientPayload",
|
||||||
|
},
|
||||||
|
{"class": "NoOp",},
|
||||||
|
{
|
||||||
|
"forceInsert": False,
|
||||||
|
"messageId": "mid.$ABC",
|
||||||
|
"threadKey": {"threadFbId": "4321"},
|
||||||
|
"class": "ForcedFetch",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"firstDeltaSeqId": 111111,
|
||||||
|
"lastIssuedSeqId": 111113,
|
||||||
|
"queueEntityId": 1234,
|
||||||
|
}
|
||||||
|
thread = Group(session=session, id="4321")
|
||||||
|
assert [
|
||||||
|
ReactionEvent(
|
||||||
|
author=User(session=session, id="1234"),
|
||||||
|
thread=thread,
|
||||||
|
message=Message(thread=thread, id="mid.$XYZ"),
|
||||||
|
reaction="😢",
|
||||||
|
),
|
||||||
|
UnfetchedThreadEvent(
|
||||||
|
thread=thread, message=Message(thread=thread, id="mid.$ABC"),
|
||||||
|
),
|
||||||
|
] == list(parse_events(session, "/t_ms", data))
|
||||||
|
|
||||||
|
|
||||||
|
def test_thread_typing(session):
|
||||||
|
data = {"sender_fbid": 1234, "state": 0, "type": "typ", "thread": "4321"}
|
||||||
|
(event,) = parse_events(session, "/thread_typing", data)
|
||||||
|
assert event == Typing(
|
||||||
|
author=User(session=session, id="1234"),
|
||||||
|
thread=Group(session=session, id="4321"),
|
||||||
|
status=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_orca_typing_notifications(session):
|
||||||
|
data = {"type": "typ", "sender_fbid": 1234, "state": 1}
|
||||||
|
(event,) = parse_events(session, "/orca_typing_notifications", data)
|
||||||
|
assert event == Typing(
|
||||||
|
author=User(session=session, id="1234"),
|
||||||
|
thread=User(session=session, id="1234"),
|
||||||
|
status=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_friend_request(session):
|
||||||
|
data = {"type": "jewel_requests_add", "from": "1234"}
|
||||||
|
(event,) = parse_events(session, "/legacy_web", data)
|
||||||
|
assert event == FriendRequest(author=User(session=session, id="1234"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_orca_presence_inc(session):
|
||||||
|
data = {
|
||||||
|
"list_type": "inc",
|
||||||
|
"list": [
|
||||||
|
{"u": 1234, "p": 0, "l": 1500000000, "vc": 74},
|
||||||
|
{"u": 2345, "p": 2, "c": 9969664, "vc": 10},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
(event,) = parse_events(session, "/orca_presence", data)
|
||||||
|
la = datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc)
|
||||||
|
assert event == Presence(
|
||||||
|
statuses={
|
||||||
|
"1234": ActiveStatus(active=False, last_active=la),
|
||||||
|
"2345": ActiveStatus(active=True),
|
||||||
|
},
|
||||||
|
full=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_orca_presence_full(session):
|
||||||
|
data = {
|
||||||
|
"list_type": "full",
|
||||||
|
"list": [
|
||||||
|
{"u": 1234, "p": 2, "c": 5767242},
|
||||||
|
{"u": 2345, "p": 2, "l": 1500000000},
|
||||||
|
{"u": 3456, "p": 2, "c": 9961482},
|
||||||
|
{"u": 4567, "p": 0, "l": 1500000000},
|
||||||
|
{"u": 5678, "p": 0},
|
||||||
|
{"u": 6789, "p": 2, "c": 14168154},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
(event,) = parse_events(session, "/orca_presence", data)
|
||||||
|
la = datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc)
|
||||||
|
assert event == Presence(
|
||||||
|
statuses={
|
||||||
|
"1234": ActiveStatus(active=True),
|
||||||
|
"2345": ActiveStatus(active=True, last_active=la),
|
||||||
|
"3456": ActiveStatus(active=True),
|
||||||
|
"4567": ActiveStatus(active=False, last_active=la),
|
||||||
|
"5678": ActiveStatus(active=False),
|
||||||
|
"6789": ActiveStatus(active=True),
|
||||||
|
},
|
||||||
|
full=True,
|
||||||
|
)
|
||||||
459
tests/models/test_attachment.py
Normal file
459
tests/models/test_attachment.py
Normal file
@@ -0,0 +1,459 @@
|
|||||||
|
import pytest
|
||||||
|
import datetime
|
||||||
|
import fbchat
|
||||||
|
from fbchat import Image, UnsentMessage, ShareAttachment
|
||||||
|
from fbchat._models._message import graphql_to_extensible_attachment
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_unsent_message():
|
||||||
|
data = {
|
||||||
|
"legacy_attachment_id": "ee.mid.$xyz",
|
||||||
|
"story_attachment": {
|
||||||
|
"description": {"text": "You removed a message"},
|
||||||
|
"media": None,
|
||||||
|
"source": None,
|
||||||
|
"style_list": ["globally_deleted_message_placeholder", "fallback"],
|
||||||
|
"title_with_entities": {"text": ""},
|
||||||
|
"properties": [],
|
||||||
|
"url": None,
|
||||||
|
"deduplication_key": "deadbeef123",
|
||||||
|
"action_links": [],
|
||||||
|
"messaging_attribution": None,
|
||||||
|
"messenger_call_to_actions": [],
|
||||||
|
"xma_layout_info": None,
|
||||||
|
"target": None,
|
||||||
|
"subattachments": [],
|
||||||
|
},
|
||||||
|
"genie_attachment": {"genie_message": None},
|
||||||
|
}
|
||||||
|
assert UnsentMessage(id="ee.mid.$xyz") == graphql_to_extensible_attachment(data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_share_from_graphql_minimal():
|
||||||
|
data = {
|
||||||
|
"target": {},
|
||||||
|
"url": "a.com",
|
||||||
|
"title_with_entities": {"text": "a.com"},
|
||||||
|
"subattachments": [],
|
||||||
|
}
|
||||||
|
assert ShareAttachment(
|
||||||
|
url="a.com", original_url="a.com", title="a.com"
|
||||||
|
) == ShareAttachment._from_graphql(data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_share_from_graphql_link():
|
||||||
|
data = {
|
||||||
|
"description": {"text": ""},
|
||||||
|
"media": {
|
||||||
|
"animated_image": None,
|
||||||
|
"image": None,
|
||||||
|
"playable_duration_in_ms": 0,
|
||||||
|
"is_playable": False,
|
||||||
|
"playable_url": None,
|
||||||
|
},
|
||||||
|
"source": {"text": "a.com"},
|
||||||
|
"style_list": ["share", "fallback"],
|
||||||
|
"title_with_entities": {"text": "a.com"},
|
||||||
|
"properties": [],
|
||||||
|
"url": "http://l.facebook.com/l.php?u=http%3A%2F%2Fa.com%2F&h=def&s=1",
|
||||||
|
"deduplication_key": "ee.mid.$xyz",
|
||||||
|
"action_links": [{"title": "About this website", "url": None}],
|
||||||
|
"messaging_attribution": None,
|
||||||
|
"messenger_call_to_actions": [],
|
||||||
|
"xma_layout_info": None,
|
||||||
|
"target": {"__typename": "ExternalUrl"},
|
||||||
|
"subattachments": [],
|
||||||
|
}
|
||||||
|
assert ShareAttachment(
|
||||||
|
author=None,
|
||||||
|
url="http://l.facebook.com/l.php?u=http%3A%2F%2Fa.com%2F&h=def&s=1",
|
||||||
|
original_url="http://a.com/",
|
||||||
|
title="a.com",
|
||||||
|
description="",
|
||||||
|
source="a.com",
|
||||||
|
image=None,
|
||||||
|
original_image_url=None,
|
||||||
|
attachments=[],
|
||||||
|
id="ee.mid.$xyz",
|
||||||
|
) == ShareAttachment._from_graphql(data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_share_from_graphql_link_with_image():
|
||||||
|
data = {
|
||||||
|
"description": {
|
||||||
|
"text": (
|
||||||
|
"Create an account or log in to Facebook."
|
||||||
|
" Connect with friends, family and other people you know."
|
||||||
|
" Share photos and videos, send messages and get updates."
|
||||||
|
)
|
||||||
|
},
|
||||||
|
"media": {
|
||||||
|
"animated_image": None,
|
||||||
|
"image": {
|
||||||
|
"uri": "https://www.facebook.com/rsrc.php/v3/x.png",
|
||||||
|
"height": 325,
|
||||||
|
"width": 325,
|
||||||
|
},
|
||||||
|
"playable_duration_in_ms": 0,
|
||||||
|
"is_playable": False,
|
||||||
|
"playable_url": None,
|
||||||
|
},
|
||||||
|
"source": None,
|
||||||
|
"style_list": ["share", "fallback"],
|
||||||
|
"title_with_entities": {"text": "Facebook – log in or sign up"},
|
||||||
|
"properties": [],
|
||||||
|
"url": "http://facebook.com/",
|
||||||
|
"deduplication_key": "deadbeef123",
|
||||||
|
"action_links": [],
|
||||||
|
"messaging_attribution": None,
|
||||||
|
"messenger_call_to_actions": [],
|
||||||
|
"xma_layout_info": None,
|
||||||
|
"target": {"__typename": "ExternalUrl"},
|
||||||
|
"subattachments": [],
|
||||||
|
}
|
||||||
|
assert ShareAttachment(
|
||||||
|
author=None,
|
||||||
|
url="http://facebook.com/",
|
||||||
|
original_url="http://facebook.com/",
|
||||||
|
title="Facebook – log in or sign up",
|
||||||
|
description=(
|
||||||
|
"Create an account or log in to Facebook."
|
||||||
|
" Connect with friends, family and other people you know."
|
||||||
|
" Share photos and videos, send messages and get updates."
|
||||||
|
),
|
||||||
|
source=None,
|
||||||
|
image=Image(
|
||||||
|
url="https://www.facebook.com/rsrc.php/v3/x.png", width=325, height=325
|
||||||
|
),
|
||||||
|
original_image_url="https://www.facebook.com/rsrc.php/v3/x.png",
|
||||||
|
attachments=[],
|
||||||
|
id="deadbeef123",
|
||||||
|
) == ShareAttachment._from_graphql(data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_share_from_graphql_video():
|
||||||
|
data = {
|
||||||
|
"description": {
|
||||||
|
"text": (
|
||||||
|
"Rick Astley's official music video for “Never Gonna Give You Up”"
|
||||||
|
" Listen to Rick Astley: https://RickAstley.lnk.to/_listenYD"
|
||||||
|
" Subscribe to the official Rick As..."
|
||||||
|
)
|
||||||
|
},
|
||||||
|
"media": {
|
||||||
|
"animated_image": None,
|
||||||
|
"image": {
|
||||||
|
"uri": (
|
||||||
|
"https://external-arn2-1.xx.fbcdn.net/safe_image.php?d=xyz123"
|
||||||
|
"&w=960&h=540&url=https%3A%2F%2Fi.ytimg.com%2Fvi%2FdQw4w9WgXcQ"
|
||||||
|
"%2Fmaxresdefault.jpg&sx=0&sy=0&sw=1280&sh=720&_nc_hash=abc123"
|
||||||
|
),
|
||||||
|
"height": 540,
|
||||||
|
"width": 960,
|
||||||
|
},
|
||||||
|
"playable_duration_in_ms": 0,
|
||||||
|
"is_playable": True,
|
||||||
|
"playable_url": "https://www.youtube.com/embed/dQw4w9WgXcQ?autoplay=1",
|
||||||
|
},
|
||||||
|
"source": {"text": "youtube.com"},
|
||||||
|
"style_list": ["share", "fallback"],
|
||||||
|
"title_with_entities": {
|
||||||
|
"text": "Rick Astley - Never Gonna Give You Up (Video)"
|
||||||
|
},
|
||||||
|
"properties": [
|
||||||
|
{"key": "width", "value": {"text": "1280"}},
|
||||||
|
{"key": "height", "value": {"text": "720"}},
|
||||||
|
],
|
||||||
|
"url": "https://l.facebook.com/l.php?u=https%3A%2F%2Fyoutu.be%2FdQw4w9WgXcQ",
|
||||||
|
"deduplication_key": "ee.mid.$gAAT4Sw1WSGhzQ9uRWVtEpZHZ8ZPV",
|
||||||
|
"action_links": [{"title": "About this website", "url": None}],
|
||||||
|
"messaging_attribution": None,
|
||||||
|
"messenger_call_to_actions": [],
|
||||||
|
"xma_layout_info": None,
|
||||||
|
"target": {"__typename": "ExternalUrl"},
|
||||||
|
"subattachments": [],
|
||||||
|
}
|
||||||
|
assert ShareAttachment(
|
||||||
|
author=None,
|
||||||
|
url="https://l.facebook.com/l.php?u=https%3A%2F%2Fyoutu.be%2FdQw4w9WgXcQ",
|
||||||
|
original_url="https://youtu.be/dQw4w9WgXcQ",
|
||||||
|
title="Rick Astley - Never Gonna Give You Up (Video)",
|
||||||
|
description=(
|
||||||
|
"Rick Astley's official music video for “Never Gonna Give You Up”"
|
||||||
|
" Listen to Rick Astley: https://RickAstley.lnk.to/_listenYD"
|
||||||
|
" Subscribe to the official Rick As..."
|
||||||
|
),
|
||||||
|
source="youtube.com",
|
||||||
|
image=Image(
|
||||||
|
url="https://external-arn2-1.xx.fbcdn.net/safe_image.php?d=xyz123"
|
||||||
|
"&w=960&h=540&url=https%3A%2F%2Fi.ytimg.com%2Fvi%2FdQw4w9WgXcQ"
|
||||||
|
"%2Fmaxresdefault.jpg&sx=0&sy=0&sw=1280&sh=720&_nc_hash=abc123",
|
||||||
|
width=960,
|
||||||
|
height=540,
|
||||||
|
),
|
||||||
|
original_image_url="https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg",
|
||||||
|
attachments=[],
|
||||||
|
id="ee.mid.$gAAT4Sw1WSGhzQ9uRWVtEpZHZ8ZPV",
|
||||||
|
) == ShareAttachment._from_graphql(data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_share_with_image_subattachment():
|
||||||
|
data = {
|
||||||
|
"description": {"text": "Abc"},
|
||||||
|
"media": {
|
||||||
|
"animated_image": None,
|
||||||
|
"image": {
|
||||||
|
"uri": "https://scontent-arn2-1.xx.fbcdn.net/v/t1.0-9/1.jpg",
|
||||||
|
"height": 960,
|
||||||
|
"width": 720,
|
||||||
|
},
|
||||||
|
"playable_duration_in_ms": 0,
|
||||||
|
"is_playable": False,
|
||||||
|
"playable_url": None,
|
||||||
|
},
|
||||||
|
"source": {"text": "Def"},
|
||||||
|
"style_list": ["attached_story", "fallback"],
|
||||||
|
"title_with_entities": {"text": ""},
|
||||||
|
"properties": [],
|
||||||
|
"url": "https://www.facebook.com/groups/11223344/permalink/1234/",
|
||||||
|
"deduplication_key": "deadbeef123",
|
||||||
|
"action_links": [
|
||||||
|
{"title": None, "url": None},
|
||||||
|
{"title": None, "url": "https://www.facebook.com/groups/11223344/"},
|
||||||
|
{
|
||||||
|
"title": "Report Post to Admin",
|
||||||
|
"url": "https://www.facebook.com/groups/11223344/members/",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"messaging_attribution": None,
|
||||||
|
"messenger_call_to_actions": [],
|
||||||
|
"xma_layout_info": None,
|
||||||
|
"target": {
|
||||||
|
"__typename": "Story",
|
||||||
|
"title": None,
|
||||||
|
"description": {"text": "Abc"},
|
||||||
|
"actors": [
|
||||||
|
{
|
||||||
|
"__typename": "User",
|
||||||
|
"name": "Def",
|
||||||
|
"id": "1111",
|
||||||
|
"short_name": "Def",
|
||||||
|
"url": "https://www.facebook.com/some-user",
|
||||||
|
"profile_picture": {
|
||||||
|
"uri": "https://scontent-arn2-1.xx.fbcdn.net/v/t1.0-1/c123.123.123.123a/s50x50/img.jpg",
|
||||||
|
"height": 50,
|
||||||
|
"width": 50,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"to": {
|
||||||
|
"__typename": "Group",
|
||||||
|
"name": "Some group",
|
||||||
|
"url": "https://www.facebook.com/groups/11223344/",
|
||||||
|
},
|
||||||
|
"attachments": [
|
||||||
|
{
|
||||||
|
"url": "https://www.facebook.com/photo.php?fbid=4321&set=gm.1234&type=3",
|
||||||
|
"media": {
|
||||||
|
"is_playable": False,
|
||||||
|
"image": {
|
||||||
|
"uri": "https://scontent-arn2-1.xx.fbcdn.net/v/t1.0-9/1.jpg",
|
||||||
|
"height": 960,
|
||||||
|
"width": 720,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"attached_story": None,
|
||||||
|
},
|
||||||
|
"subattachments": [
|
||||||
|
{
|
||||||
|
"description": {"text": "Abc"},
|
||||||
|
"media": {
|
||||||
|
"animated_image": None,
|
||||||
|
"image": {
|
||||||
|
"uri": "https://scontent-arn2-1.xx.fbcdn.net/v/t1.0-9/1.jpg",
|
||||||
|
"height": 960,
|
||||||
|
"width": 720,
|
||||||
|
},
|
||||||
|
"playable_duration_in_ms": 0,
|
||||||
|
"is_playable": False,
|
||||||
|
"playable_url": None,
|
||||||
|
},
|
||||||
|
"source": None,
|
||||||
|
"style_list": ["photo", "games_app", "fallback"],
|
||||||
|
"title_with_entities": {"text": ""},
|
||||||
|
"properties": [
|
||||||
|
{"key": "photoset_reference_token", "value": {"text": "gm.1234"}},
|
||||||
|
{"key": "layout_x", "value": {"text": "0"}},
|
||||||
|
{"key": "layout_y", "value": {"text": "0"}},
|
||||||
|
{"key": "layout_w", "value": {"text": "0"}},
|
||||||
|
{"key": "layout_h", "value": {"text": "0"}},
|
||||||
|
],
|
||||||
|
"url": "https://www.facebook.com/photo.php?fbid=4321&set=gm.1234&type=3",
|
||||||
|
"deduplication_key": "deadbeef456",
|
||||||
|
"action_links": [],
|
||||||
|
"messaging_attribution": None,
|
||||||
|
"messenger_call_to_actions": [],
|
||||||
|
"xma_layout_info": None,
|
||||||
|
"target": {"__typename": "Photo"},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
assert ShareAttachment(
|
||||||
|
author="1111",
|
||||||
|
url="https://www.facebook.com/groups/11223344/permalink/1234/",
|
||||||
|
original_url="https://www.facebook.com/groups/11223344/permalink/1234/",
|
||||||
|
title="",
|
||||||
|
description="Abc",
|
||||||
|
source="Def",
|
||||||
|
image=Image(
|
||||||
|
url="https://scontent-arn2-1.xx.fbcdn.net/v/t1.0-9/1.jpg",
|
||||||
|
width=720,
|
||||||
|
height=960,
|
||||||
|
),
|
||||||
|
original_image_url="https://scontent-arn2-1.xx.fbcdn.net/v/t1.0-9/1.jpg",
|
||||||
|
attachments=[None],
|
||||||
|
id="deadbeef123",
|
||||||
|
) == ShareAttachment._from_graphql(data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_share_with_video_subattachment():
|
||||||
|
data = {
|
||||||
|
"description": {"text": "Abc"},
|
||||||
|
"media": {
|
||||||
|
"animated_image": None,
|
||||||
|
"image": {
|
||||||
|
"uri": "https://scontent-arn2-1.xx.fbcdn.net/v/t15.5256-10/p180x540/1.jpg",
|
||||||
|
"height": 540,
|
||||||
|
"width": 960,
|
||||||
|
},
|
||||||
|
"playable_duration_in_ms": 24469,
|
||||||
|
"is_playable": True,
|
||||||
|
"playable_url": "https://video-arn2-1.xx.fbcdn.net/v/t42.9040-2/vid.mp4",
|
||||||
|
},
|
||||||
|
"source": {"text": "Def"},
|
||||||
|
"style_list": ["attached_story", "fallback"],
|
||||||
|
"title_with_entities": {"text": ""},
|
||||||
|
"properties": [],
|
||||||
|
"url": "https://www.facebook.com/groups/11223344/permalink/1234/",
|
||||||
|
"deduplication_key": "deadbeef123",
|
||||||
|
"action_links": [
|
||||||
|
{"title": None, "url": None},
|
||||||
|
{"title": None, "url": "https://www.facebook.com/groups/11223344/"},
|
||||||
|
{"title": None, "url": None},
|
||||||
|
{"title": "A watch party is currently playing this video.", "url": None},
|
||||||
|
],
|
||||||
|
"messaging_attribution": None,
|
||||||
|
"messenger_call_to_actions": [],
|
||||||
|
"xma_layout_info": None,
|
||||||
|
"target": {
|
||||||
|
"__typename": "Story",
|
||||||
|
"title": None,
|
||||||
|
"description": {"text": "Abc"},
|
||||||
|
"actors": [
|
||||||
|
{
|
||||||
|
"__typename": "User",
|
||||||
|
"name": "Def",
|
||||||
|
"id": "1111",
|
||||||
|
"short_name": "Def",
|
||||||
|
"url": "https://www.facebook.com/some-user",
|
||||||
|
"profile_picture": {
|
||||||
|
"uri": "https://scontent-arn2-1.xx.fbcdn.net/v/t1.0-1/c1.0.50.50a/p50x50/profile.jpg",
|
||||||
|
"height": 50,
|
||||||
|
"width": 50,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"to": {
|
||||||
|
"__typename": "Group",
|
||||||
|
"name": "Some group",
|
||||||
|
"url": "https://www.facebook.com/groups/11223344/",
|
||||||
|
},
|
||||||
|
"attachments": [
|
||||||
|
{
|
||||||
|
"url": "https://www.facebook.com/some-user/videos/2222/",
|
||||||
|
"media": {
|
||||||
|
"is_playable": True,
|
||||||
|
"image": {
|
||||||
|
"uri": "https://scontent-arn2-1.xx.fbcdn.net/v/t15.5256-10/p180x540/1.jpg",
|
||||||
|
"height": 540,
|
||||||
|
"width": 960,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"attached_story": None,
|
||||||
|
},
|
||||||
|
"subattachments": [
|
||||||
|
{
|
||||||
|
"description": None,
|
||||||
|
"media": {
|
||||||
|
"animated_image": None,
|
||||||
|
"image": {
|
||||||
|
"uri": "https://scontent-arn2-1.xx.fbcdn.net/v/t15.5256-10/p180x540/1.jpg",
|
||||||
|
"height": 540,
|
||||||
|
"width": 960,
|
||||||
|
},
|
||||||
|
"playable_duration_in_ms": 24469,
|
||||||
|
"is_playable": True,
|
||||||
|
"playable_url": "https://video-arn2-1.xx.fbcdn.net/v/t42.9040-2/vid.mp4",
|
||||||
|
},
|
||||||
|
"source": None,
|
||||||
|
"style_list": [
|
||||||
|
"video_autoplay",
|
||||||
|
"video_inline",
|
||||||
|
"video",
|
||||||
|
"games_app",
|
||||||
|
"fallback",
|
||||||
|
],
|
||||||
|
"title_with_entities": {"text": ""},
|
||||||
|
"properties": [
|
||||||
|
{
|
||||||
|
"key": "can_autoplay_result",
|
||||||
|
"value": {"text": "ugc_default_allowed"},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"url": "https://www.facebook.com/some-user/videos/2222/",
|
||||||
|
"deduplication_key": "deadbeef456",
|
||||||
|
"action_links": [],
|
||||||
|
"messaging_attribution": None,
|
||||||
|
"messenger_call_to_actions": [],
|
||||||
|
"xma_layout_info": None,
|
||||||
|
"target": {
|
||||||
|
"__typename": "Video",
|
||||||
|
"video_id": "2222",
|
||||||
|
"video_messenger_cta_payload": None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
assert ShareAttachment(
|
||||||
|
author="1111",
|
||||||
|
url="https://www.facebook.com/groups/11223344/permalink/1234/",
|
||||||
|
original_url="https://www.facebook.com/groups/11223344/permalink/1234/",
|
||||||
|
title="",
|
||||||
|
description="Abc",
|
||||||
|
source="Def",
|
||||||
|
image=Image(
|
||||||
|
url="https://scontent-arn2-1.xx.fbcdn.net/v/t15.5256-10/p180x540/1.jpg",
|
||||||
|
width=960,
|
||||||
|
height=540,
|
||||||
|
),
|
||||||
|
original_image_url="https://scontent-arn2-1.xx.fbcdn.net/v/t15.5256-10/p180x540/1.jpg",
|
||||||
|
attachments=[
|
||||||
|
fbchat.VideoAttachment(
|
||||||
|
id="2222",
|
||||||
|
duration=datetime.timedelta(seconds=24, microseconds=469000),
|
||||||
|
preview_url="https://video-arn2-1.xx.fbcdn.net/v/t42.9040-2/vid.mp4",
|
||||||
|
previews={
|
||||||
|
Image(
|
||||||
|
url="https://scontent-arn2-1.xx.fbcdn.net/v/t15.5256-10/p180x540/1.jpg",
|
||||||
|
width=960,
|
||||||
|
height=540,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
id="deadbeef123",
|
||||||
|
) == ShareAttachment._from_graphql(data)
|
||||||
358
tests/models/test_file.py
Normal file
358
tests/models/test_file.py
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
import datetime
|
||||||
|
import fbchat
|
||||||
|
from fbchat import (
|
||||||
|
Image,
|
||||||
|
FileAttachment,
|
||||||
|
AudioAttachment,
|
||||||
|
ImageAttachment,
|
||||||
|
VideoAttachment,
|
||||||
|
)
|
||||||
|
from fbchat._models._file import graphql_to_attachment, graphql_to_subattachment
|
||||||
|
|
||||||
|
|
||||||
|
def test_imageattachment_from_list():
|
||||||
|
data = {
|
||||||
|
"__typename": "MessageImage",
|
||||||
|
"id": "bWVzc2...",
|
||||||
|
"legacy_attachment_id": "1234",
|
||||||
|
"image": {"uri": "https://scontent-arn2-1.xx.fbcdn.net/v/s261x260/1.jpg"},
|
||||||
|
"image1": {
|
||||||
|
"height": 463,
|
||||||
|
"width": 960,
|
||||||
|
"uri": "https://scontent-arn2-1.xx.fbcdn.net/v/2.jpg",
|
||||||
|
},
|
||||||
|
"image2": {
|
||||||
|
"height": 988,
|
||||||
|
"width": 2048,
|
||||||
|
"uri": "https://scontent-arn2-1.xx.fbcdn.net/v/s2048x2048/3.jpg",
|
||||||
|
},
|
||||||
|
"original_dimensions": {"x": 2833, "y": 1367},
|
||||||
|
"photo_encodings": [],
|
||||||
|
}
|
||||||
|
assert ImageAttachment(
|
||||||
|
id="1234",
|
||||||
|
width=2833,
|
||||||
|
height=1367,
|
||||||
|
previews={
|
||||||
|
Image(url="https://scontent-arn2-1.xx.fbcdn.net/v/s261x260/1.jpg"),
|
||||||
|
Image(
|
||||||
|
url="https://scontent-arn2-1.xx.fbcdn.net/v/2.jpg",
|
||||||
|
width=960,
|
||||||
|
height=463,
|
||||||
|
),
|
||||||
|
Image(
|
||||||
|
url="https://scontent-arn2-1.xx.fbcdn.net/v/s2048x2048/3.jpg",
|
||||||
|
width=2048,
|
||||||
|
height=988,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
) == ImageAttachment._from_list(data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_videoattachment_from_list():
|
||||||
|
data = {
|
||||||
|
"__typename": "MessageVideo",
|
||||||
|
"id": "bWVzc2...",
|
||||||
|
"legacy_attachment_id": "1234",
|
||||||
|
"image": {
|
||||||
|
"uri": "https://scontent-arn2-1.xx.fbcdn.net/v/t15.3394-10/p261x260/1.jpg"
|
||||||
|
},
|
||||||
|
"image1": {
|
||||||
|
"height": 368,
|
||||||
|
"width": 640,
|
||||||
|
"uri": "https://scontent-arn2-1.xx.fbcdn.net/v/t15.3394-10/2.jpg",
|
||||||
|
},
|
||||||
|
"image2": {
|
||||||
|
"height": 368,
|
||||||
|
"width": 640,
|
||||||
|
"uri": "https://scontent-arn2-1.xx.fbcdn.net/v/t15.3394-10/3.jpg",
|
||||||
|
},
|
||||||
|
"original_dimensions": {"x": 640, "y": 368},
|
||||||
|
}
|
||||||
|
assert VideoAttachment(
|
||||||
|
id="1234",
|
||||||
|
width=640,
|
||||||
|
height=368,
|
||||||
|
previews={
|
||||||
|
Image(
|
||||||
|
url="https://scontent-arn2-1.xx.fbcdn.net/v/t15.3394-10/p261x260/1.jpg"
|
||||||
|
),
|
||||||
|
Image(
|
||||||
|
url="https://scontent-arn2-1.xx.fbcdn.net/v/t15.3394-10/2.jpg",
|
||||||
|
width=640,
|
||||||
|
height=368,
|
||||||
|
),
|
||||||
|
Image(
|
||||||
|
url="https://scontent-arn2-1.xx.fbcdn.net/v/t15.3394-10/3.jpg",
|
||||||
|
width=640,
|
||||||
|
height=368,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
) == VideoAttachment._from_list(data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_graphql_to_attachment_empty():
|
||||||
|
assert fbchat.Attachment() == graphql_to_attachment({"__typename": "Unknown"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_graphql_to_attachment_simple():
|
||||||
|
data = {"__typename": "Unknown", "legacy_attachment_id": "1234"}
|
||||||
|
assert fbchat.Attachment(id="1234") == graphql_to_attachment(data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_graphql_to_attachment_file():
|
||||||
|
data = {
|
||||||
|
"__typename": "MessageFile",
|
||||||
|
"attribution_app": None,
|
||||||
|
"attribution_metadata": None,
|
||||||
|
"filename": "file.txt",
|
||||||
|
"url": "https://l.facebook.com/l.php?u=https%3A%2F%2Fcdn.fbsbx.com%2Fv%2Ffile.txt&h=AT1...&s=1",
|
||||||
|
"content_type": "attach:text",
|
||||||
|
"is_malicious": False,
|
||||||
|
"message_file_fbid": "1234",
|
||||||
|
"url_shimhash": "AT0...",
|
||||||
|
"url_skipshim": True,
|
||||||
|
}
|
||||||
|
assert FileAttachment(
|
||||||
|
id="1234",
|
||||||
|
url="https://l.facebook.com/l.php?u=https%3A%2F%2Fcdn.fbsbx.com%2Fv%2Ffile.txt&h=AT1...&s=1",
|
||||||
|
size=None,
|
||||||
|
name="file.txt",
|
||||||
|
is_malicious=False,
|
||||||
|
) == graphql_to_attachment(data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_graphql_to_attachment_audio():
|
||||||
|
data = {
|
||||||
|
"__typename": "MessageAudio",
|
||||||
|
"attribution_app": None,
|
||||||
|
"attribution_metadata": None,
|
||||||
|
"filename": "audio.mp3",
|
||||||
|
"playable_url": "https://cdn.fbsbx.com/v/audio.mp3?dl=1",
|
||||||
|
"playable_duration_in_ms": 27745,
|
||||||
|
"is_voicemail": False,
|
||||||
|
"audio_type": "FILE_ATTACHMENT",
|
||||||
|
"url_shimhash": "AT0...",
|
||||||
|
"url_skipshim": True,
|
||||||
|
}
|
||||||
|
assert AudioAttachment(
|
||||||
|
id=None,
|
||||||
|
filename="audio.mp3",
|
||||||
|
url="https://cdn.fbsbx.com/v/audio.mp3?dl=1",
|
||||||
|
duration=datetime.timedelta(seconds=27, microseconds=745000),
|
||||||
|
audio_type="FILE_ATTACHMENT",
|
||||||
|
) == graphql_to_attachment(data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_graphql_to_attachment_image1():
|
||||||
|
data = {
|
||||||
|
"__typename": "MessageImage",
|
||||||
|
"attribution_app": None,
|
||||||
|
"attribution_metadata": None,
|
||||||
|
"filename": "image-1234",
|
||||||
|
"preview": {
|
||||||
|
"uri": "https://scontent-arn2-1.xx.fbcdn.net/v/1.png",
|
||||||
|
"height": 128,
|
||||||
|
"width": 128,
|
||||||
|
},
|
||||||
|
"large_preview": {
|
||||||
|
"uri": "https://scontent-arn2-1.xx.fbcdn.net/v/1.png",
|
||||||
|
"height": 128,
|
||||||
|
"width": 128,
|
||||||
|
},
|
||||||
|
"thumbnail": {"uri": "https://scontent-arn2-1.xx.fbcdn.net/v/p50x50/2.png"},
|
||||||
|
"photo_encodings": [],
|
||||||
|
"legacy_attachment_id": "1234",
|
||||||
|
"original_dimensions": {"x": 128, "y": 128},
|
||||||
|
"original_extension": "png",
|
||||||
|
"render_as_sticker": False,
|
||||||
|
"blurred_image_uri": None,
|
||||||
|
}
|
||||||
|
assert ImageAttachment(
|
||||||
|
id="1234",
|
||||||
|
original_extension="png",
|
||||||
|
width=None,
|
||||||
|
height=None,
|
||||||
|
is_animated=False,
|
||||||
|
previews={
|
||||||
|
Image(url="https://scontent-arn2-1.xx.fbcdn.net/v/p50x50/2.png"),
|
||||||
|
Image(
|
||||||
|
url="https://scontent-arn2-1.xx.fbcdn.net/v/1.png",
|
||||||
|
width=128,
|
||||||
|
height=128,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
) == graphql_to_attachment(data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_graphql_to_attachment_image2():
|
||||||
|
data = {
|
||||||
|
"__typename": "MessageAnimatedImage",
|
||||||
|
"attribution_app": None,
|
||||||
|
"attribution_metadata": None,
|
||||||
|
"filename": "gif-1234",
|
||||||
|
"animated_image": {
|
||||||
|
"uri": "https://cdn.fbsbx.com/v/1.gif",
|
||||||
|
"height": 128,
|
||||||
|
"width": 128,
|
||||||
|
},
|
||||||
|
"legacy_attachment_id": "1234",
|
||||||
|
"preview_image": {
|
||||||
|
"uri": "https://cdn.fbsbx.com/v/1.gif",
|
||||||
|
"height": 128,
|
||||||
|
"width": 128,
|
||||||
|
},
|
||||||
|
"original_dimensions": {"x": 128, "y": 128},
|
||||||
|
}
|
||||||
|
assert ImageAttachment(
|
||||||
|
id="1234",
|
||||||
|
original_extension="gif",
|
||||||
|
width=None,
|
||||||
|
height=None,
|
||||||
|
is_animated=True,
|
||||||
|
previews={Image(url="https://cdn.fbsbx.com/v/1.gif", width=128, height=128)},
|
||||||
|
) == graphql_to_attachment(data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_graphql_to_attachment_video():
|
||||||
|
data = {
|
||||||
|
"__typename": "MessageVideo",
|
||||||
|
"attribution_app": None,
|
||||||
|
"attribution_metadata": None,
|
||||||
|
"filename": "video-4321.mp4",
|
||||||
|
"playable_url": "https://video-arn2-1.xx.fbcdn.net/v/video-4321.mp4",
|
||||||
|
"chat_image": {
|
||||||
|
"height": 96,
|
||||||
|
"width": 168,
|
||||||
|
"uri": "https://scontent-arn2-1.xx.fbcdn.net/v/s168x128/1.jpg",
|
||||||
|
},
|
||||||
|
"legacy_attachment_id": "1234",
|
||||||
|
"video_type": "FILE_ATTACHMENT",
|
||||||
|
"original_dimensions": {"x": 640, "y": 368},
|
||||||
|
"playable_duration_in_ms": 6000,
|
||||||
|
"large_image": {
|
||||||
|
"height": 368,
|
||||||
|
"width": 640,
|
||||||
|
"uri": "https://scontent-arn2-1.xx.fbcdn.net/v/2.jpg",
|
||||||
|
},
|
||||||
|
"inbox_image": {
|
||||||
|
"height": 260,
|
||||||
|
"width": 452,
|
||||||
|
"uri": "https://scontent-arn2-1.xx.fbcdn.net/v/p261x260/3.jpg",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
assert VideoAttachment(
|
||||||
|
id="1234",
|
||||||
|
width=None,
|
||||||
|
height=None,
|
||||||
|
duration=datetime.timedelta(seconds=6),
|
||||||
|
preview_url="https://video-arn2-1.xx.fbcdn.net/v/video-4321.mp4",
|
||||||
|
previews={
|
||||||
|
Image(
|
||||||
|
url="https://scontent-arn2-1.xx.fbcdn.net/v/s168x128/1.jpg",
|
||||||
|
width=168,
|
||||||
|
height=96,
|
||||||
|
),
|
||||||
|
Image(
|
||||||
|
url="https://scontent-arn2-1.xx.fbcdn.net/v/p261x260/3.jpg",
|
||||||
|
width=452,
|
||||||
|
height=260,
|
||||||
|
),
|
||||||
|
Image(
|
||||||
|
url="https://scontent-arn2-1.xx.fbcdn.net/v/2.jpg",
|
||||||
|
width=640,
|
||||||
|
height=368,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
) == graphql_to_attachment(data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_graphql_to_subattachment_empty():
|
||||||
|
assert None is graphql_to_subattachment({})
|
||||||
|
|
||||||
|
|
||||||
|
def test_graphql_to_subattachment_image():
|
||||||
|
data = {
|
||||||
|
"description": {"text": "Abc"},
|
||||||
|
"media": {
|
||||||
|
"animated_image": None,
|
||||||
|
"image": {
|
||||||
|
"uri": "https://scontent-arn2-1.xx.fbcdn.net/v/t1.0-9/1.jpg",
|
||||||
|
"height": 960,
|
||||||
|
"width": 720,
|
||||||
|
},
|
||||||
|
"playable_duration_in_ms": 0,
|
||||||
|
"is_playable": False,
|
||||||
|
"playable_url": None,
|
||||||
|
},
|
||||||
|
"source": None,
|
||||||
|
"style_list": ["photo", "games_app", "fallback"],
|
||||||
|
"title_with_entities": {"text": ""},
|
||||||
|
"properties": [
|
||||||
|
{"key": "photoset_reference_token", "value": {"text": "gm.4321"}},
|
||||||
|
{"key": "layout_x", "value": {"text": "0"}},
|
||||||
|
{"key": "layout_y", "value": {"text": "0"}},
|
||||||
|
{"key": "layout_w", "value": {"text": "0"}},
|
||||||
|
{"key": "layout_h", "value": {"text": "0"}},
|
||||||
|
],
|
||||||
|
"url": "https://www.facebook.com/photo.php?fbid=1234&set=gm.4321&type=3",
|
||||||
|
"deduplication_key": "8334...",
|
||||||
|
"action_links": [],
|
||||||
|
"messaging_attribution": None,
|
||||||
|
"messenger_call_to_actions": [],
|
||||||
|
"xma_layout_info": None,
|
||||||
|
"target": {"__typename": "Photo"},
|
||||||
|
}
|
||||||
|
assert None is graphql_to_subattachment(data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_graphql_to_subattachment_video():
|
||||||
|
data = {
|
||||||
|
"description": None,
|
||||||
|
"media": {
|
||||||
|
"animated_image": None,
|
||||||
|
"image": {
|
||||||
|
"uri": "https://scontent-arn2-1.xx.fbcdn.net/v/t15.5256-10/p180x540/1.jpg",
|
||||||
|
"height": 540,
|
||||||
|
"width": 960,
|
||||||
|
},
|
||||||
|
"playable_duration_in_ms": 24469,
|
||||||
|
"is_playable": True,
|
||||||
|
"playable_url": "https://video-arn2-1.xx.fbcdn.net/v/t42.9040-2/vid.mp4",
|
||||||
|
},
|
||||||
|
"source": None,
|
||||||
|
"style_list": [
|
||||||
|
"video_autoplay",
|
||||||
|
"video_inline",
|
||||||
|
"video",
|
||||||
|
"games_app",
|
||||||
|
"fallback",
|
||||||
|
],
|
||||||
|
"title_with_entities": {"text": ""},
|
||||||
|
"properties": [
|
||||||
|
{"key": "can_autoplay_result", "value": {"text": "ugc_default_allowed"}}
|
||||||
|
],
|
||||||
|
"url": "https://www.facebook.com/some-username/videos/1234/",
|
||||||
|
"deduplication_key": "ddb7...",
|
||||||
|
"action_links": [],
|
||||||
|
"messaging_attribution": None,
|
||||||
|
"messenger_call_to_actions": [],
|
||||||
|
"xma_layout_info": None,
|
||||||
|
"target": {
|
||||||
|
"__typename": "Video",
|
||||||
|
"video_id": "1234",
|
||||||
|
"video_messenger_cta_payload": None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
assert VideoAttachment(
|
||||||
|
id="1234",
|
||||||
|
duration=datetime.timedelta(seconds=24, microseconds=469000),
|
||||||
|
preview_url="https://video-arn2-1.xx.fbcdn.net/v/t42.9040-2/vid.mp4",
|
||||||
|
previews={
|
||||||
|
Image(
|
||||||
|
url="https://scontent-arn2-1.xx.fbcdn.net/v/t15.5256-10/p180x540/1.jpg",
|
||||||
|
width=960,
|
||||||
|
height=540,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) == graphql_to_subattachment(data)
|
||||||
96
tests/models/test_location.py
Normal file
96
tests/models/test_location.py
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import pytest
|
||||||
|
import datetime
|
||||||
|
import fbchat
|
||||||
|
from fbchat import Image, LocationAttachment, LiveLocationAttachment
|
||||||
|
|
||||||
|
|
||||||
|
def test_location_attachment_from_graphql():
|
||||||
|
data = {
|
||||||
|
"description": {"text": ""},
|
||||||
|
"media": {
|
||||||
|
"animated_image": None,
|
||||||
|
"image": {
|
||||||
|
"uri": "https://external-arn2-1.xx.fbcdn.net/static_map.php?v=1020&osm_provider=2&size=545x280&zoom=15&markers=55.40000000%2C12.43220000&language=en",
|
||||||
|
"height": 280,
|
||||||
|
"width": 545,
|
||||||
|
},
|
||||||
|
"playable_duration_in_ms": 0,
|
||||||
|
"is_playable": False,
|
||||||
|
"playable_url": None,
|
||||||
|
},
|
||||||
|
"source": None,
|
||||||
|
"style_list": ["message_location", "fallback"],
|
||||||
|
"title_with_entities": {"text": "Your location"},
|
||||||
|
"properties": [
|
||||||
|
{"key": "width", "value": {"text": "545"}},
|
||||||
|
{"key": "height", "value": {"text": "280"}},
|
||||||
|
],
|
||||||
|
"url": "https://l.facebook.com/l.php?u=https%3A%2F%2Fwww.bing.com%2Fmaps%2Fdefault.aspx%3Fv%3D2%26pc%3DFACEBK%26mid%3D8100%26where1%3D55.4%252C%2B12.4322%26FORM%3DFBKPL1%26mkt%3Den-GB&h=a&s=1",
|
||||||
|
"deduplication_key": "400828513928715",
|
||||||
|
"action_links": [],
|
||||||
|
"messaging_attribution": None,
|
||||||
|
"messenger_call_to_actions": [],
|
||||||
|
"xma_layout_info": None,
|
||||||
|
"target": {"__typename": "MessageLocation"},
|
||||||
|
"subattachments": [],
|
||||||
|
}
|
||||||
|
assert LocationAttachment(
|
||||||
|
id=400828513928715,
|
||||||
|
latitude=55.4,
|
||||||
|
longitude=12.4322,
|
||||||
|
image=Image(
|
||||||
|
url="https://external-arn2-1.xx.fbcdn.net/static_map.php?v=1020&osm_provider=2&size=545x280&zoom=15&markers=55.40000000%2C12.43220000&language=en",
|
||||||
|
width=545,
|
||||||
|
height=280,
|
||||||
|
),
|
||||||
|
url="https://l.facebook.com/l.php?u=https%3A%2F%2Fwww.bing.com%2Fmaps%2Fdefault.aspx%3Fv%3D2%26pc%3DFACEBK%26mid%3D8100%26where1%3D55.4%252C%2B12.4322%26FORM%3DFBKPL1%26mkt%3Den-GB&h=a&s=1",
|
||||||
|
) == LocationAttachment._from_graphql(data)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skip(reason="need to gather test data")
|
||||||
|
def test_live_location_from_pull():
|
||||||
|
data = ...
|
||||||
|
assert LiveLocationAttachment(...) == LiveLocationAttachment._from_pull(data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_live_location_from_graphql_expired():
|
||||||
|
data = {
|
||||||
|
"description": {"text": "Last update 4 Jan"},
|
||||||
|
"media": None,
|
||||||
|
"source": None,
|
||||||
|
"style_list": ["message_live_location", "fallback"],
|
||||||
|
"title_with_entities": {"text": "Location-sharing ended"},
|
||||||
|
"properties": [],
|
||||||
|
"url": "https://www.facebook.com/",
|
||||||
|
"deduplication_key": "2254535444791641",
|
||||||
|
"action_links": [],
|
||||||
|
"messaging_attribution": None,
|
||||||
|
"messenger_call_to_actions": [],
|
||||||
|
"target": {
|
||||||
|
"__typename": "MessageLiveLocation",
|
||||||
|
"live_location_id": "2254535444791641",
|
||||||
|
"is_expired": True,
|
||||||
|
"expiration_time": 1546626345,
|
||||||
|
"sender": {"id": "100007056224713"},
|
||||||
|
"coordinate": None,
|
||||||
|
"location_title": None,
|
||||||
|
"sender_destination": None,
|
||||||
|
"stop_reason": "CANCELED",
|
||||||
|
},
|
||||||
|
"subattachments": [],
|
||||||
|
}
|
||||||
|
assert LiveLocationAttachment(
|
||||||
|
id=2254535444791641,
|
||||||
|
name="Location-sharing ended",
|
||||||
|
expires_at=datetime.datetime(
|
||||||
|
2019, 1, 4, 18, 25, 45, tzinfo=datetime.timezone.utc
|
||||||
|
),
|
||||||
|
is_expired=True,
|
||||||
|
url="https://www.facebook.com/",
|
||||||
|
) == LiveLocationAttachment._from_graphql(data)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skip(reason="need to gather test data")
|
||||||
|
def test_live_location_from_graphql():
|
||||||
|
data = ...
|
||||||
|
assert LiveLocationAttachment(...) == LiveLocationAttachment._from_graphql(data)
|
||||||
118
tests/models/test_message.py
Normal file
118
tests/models/test_message.py
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import pytest
|
||||||
|
import fbchat
|
||||||
|
from fbchat import EmojiSize, Mention, Message, MessageData
|
||||||
|
from fbchat._models._message import graphql_to_extensible_attachment
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"tags,size",
|
||||||
|
[
|
||||||
|
(None, None),
|
||||||
|
(["hot_emoji_size:unknown"], None),
|
||||||
|
(["bunch", "of:different", "tags:large", "hot_emoji_size:s"], EmojiSize.SMALL),
|
||||||
|
(["hot_emoji_size:s"], EmojiSize.SMALL),
|
||||||
|
(["hot_emoji_size:m"], EmojiSize.MEDIUM),
|
||||||
|
(["hot_emoji_size:l"], EmojiSize.LARGE),
|
||||||
|
(["hot_emoji_size:small"], EmojiSize.SMALL),
|
||||||
|
(["hot_emoji_size:medium"], EmojiSize.MEDIUM),
|
||||||
|
(["hot_emoji_size:large"], EmojiSize.LARGE),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_emojisize_from_tags(tags, size):
|
||||||
|
assert size is EmojiSize._from_tags(tags)
|
||||||
|
|
||||||
|
|
||||||
|
def test_graphql_to_extensible_attachment_empty():
|
||||||
|
assert None is graphql_to_extensible_attachment({})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"obj,type_",
|
||||||
|
[
|
||||||
|
# UnsentMessage testing is done in test_attachment.py
|
||||||
|
(fbchat.LocationAttachment, "MessageLocation"),
|
||||||
|
(fbchat.LiveLocationAttachment, "MessageLiveLocation"),
|
||||||
|
(fbchat.ShareAttachment, "ExternalUrl"),
|
||||||
|
(fbchat.ShareAttachment, "Story"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_graphql_to_extensible_attachment_dispatch(monkeypatch, obj, type_):
|
||||||
|
monkeypatch.setattr(obj, "_from_graphql", lambda data: True)
|
||||||
|
data = {"story_attachment": {"target": {"__typename": type_}}}
|
||||||
|
assert graphql_to_extensible_attachment(data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_mention_from_range():
|
||||||
|
data = {"length": 17, "offset": 0, "entity": {"__typename": "User", "id": "1234"}}
|
||||||
|
assert Mention(thread_id="1234", offset=0, length=17) == Mention._from_range(data)
|
||||||
|
data = {
|
||||||
|
"length": 2,
|
||||||
|
"offset": 10,
|
||||||
|
"entity": {"__typename": "MessengerViewer1To1Thread"},
|
||||||
|
}
|
||||||
|
assert Mention(thread_id=None, offset=10, length=2) == Mention._from_range(data)
|
||||||
|
data = {
|
||||||
|
"length": 5,
|
||||||
|
"offset": 21,
|
||||||
|
"entity": {"__typename": "MessengerViewerGroupThread"},
|
||||||
|
}
|
||||||
|
assert Mention(thread_id=None, offset=21, length=5) == Mention._from_range(data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_mention_to_send_data():
|
||||||
|
assert {
|
||||||
|
"profile_xmd[0][id]": "1234",
|
||||||
|
"profile_xmd[0][length]": 7,
|
||||||
|
"profile_xmd[0][offset]": 4,
|
||||||
|
"profile_xmd[0][type]": "p",
|
||||||
|
} == Mention(thread_id="1234", offset=4, length=7)._to_send_data(0)
|
||||||
|
assert {
|
||||||
|
"profile_xmd[1][id]": "4321",
|
||||||
|
"profile_xmd[1][length]": 7,
|
||||||
|
"profile_xmd[1][offset]": 24,
|
||||||
|
"profile_xmd[1][type]": "p",
|
||||||
|
} == Mention(thread_id="4321", offset=24, length=7)._to_send_data(1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_message_format_mentions():
|
||||||
|
expected = (
|
||||||
|
"Hey 'Peter'! My name is Michael",
|
||||||
|
[
|
||||||
|
Mention(thread_id="1234", offset=4, length=7),
|
||||||
|
Mention(thread_id="4321", offset=24, length=7),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert expected == Message.format_mentions(
|
||||||
|
"Hey {!r}! My name is {}", ("1234", "Peter"), ("4321", "Michael")
|
||||||
|
)
|
||||||
|
assert expected == Message.format_mentions(
|
||||||
|
"Hey {p!r}! My name is {}", ("4321", "Michael"), p=("1234", "Peter")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_message_get_forwarded_from_tags():
|
||||||
|
assert not MessageData._get_forwarded_from_tags(None)
|
||||||
|
assert not MessageData._get_forwarded_from_tags(["hot_emoji_size:unknown"])
|
||||||
|
assert MessageData._get_forwarded_from_tags(
|
||||||
|
["attachment:photo", "inbox", "sent", "source:chat:forward", "tq"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skip(reason="need to be added")
|
||||||
|
def test_message_to_send_data_quick_replies():
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skip(reason="need to gather test data")
|
||||||
|
def test_message_from_graphql():
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skip(reason="need to gather test data")
|
||||||
|
def test_message_from_reply():
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skip(reason="need to gather test data")
|
||||||
|
def test_message_from_pull():
|
||||||
|
pass
|
||||||
155
tests/models/test_plan.py
Normal file
155
tests/models/test_plan.py
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import datetime
|
||||||
|
from fbchat import GuestStatus, PlanData
|
||||||
|
|
||||||
|
|
||||||
|
def test_plan_properties(session):
|
||||||
|
plan = PlanData(
|
||||||
|
session=session,
|
||||||
|
id="1234567890",
|
||||||
|
time=...,
|
||||||
|
title=...,
|
||||||
|
guests={
|
||||||
|
"1234": GuestStatus.INVITED,
|
||||||
|
"2345": GuestStatus.INVITED,
|
||||||
|
"3456": GuestStatus.GOING,
|
||||||
|
"4567": GuestStatus.DECLINED,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert set(plan.invited) == {"1234", "2345"}
|
||||||
|
assert plan.going == ["3456"]
|
||||||
|
assert plan.declined == ["4567"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_plan_from_pull(session):
|
||||||
|
data = {
|
||||||
|
"event_timezone": "",
|
||||||
|
"event_creator_id": "1234",
|
||||||
|
"event_id": "1111",
|
||||||
|
"event_type": "EVENT",
|
||||||
|
"event_track_rsvp": "1",
|
||||||
|
"event_title": "abc",
|
||||||
|
"event_time": "1500000000",
|
||||||
|
"event_seconds_to_notify_before": "3600",
|
||||||
|
"guest_state_list": (
|
||||||
|
'[{"guest_list_state":"INVITED","node":{"id":"1234"}},'
|
||||||
|
'{"guest_list_state":"INVITED","node":{"id":"2356"}},'
|
||||||
|
'{"guest_list_state":"DECLINED","node":{"id":"3456"}},'
|
||||||
|
'{"guest_list_state":"GOING","node":{"id":"4567"}}]'
|
||||||
|
),
|
||||||
|
}
|
||||||
|
assert PlanData(
|
||||||
|
session=session,
|
||||||
|
id="1111",
|
||||||
|
time=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc),
|
||||||
|
title="abc",
|
||||||
|
author_id="1234",
|
||||||
|
guests={
|
||||||
|
"1234": GuestStatus.INVITED,
|
||||||
|
"2356": GuestStatus.INVITED,
|
||||||
|
"3456": GuestStatus.DECLINED,
|
||||||
|
"4567": GuestStatus.GOING,
|
||||||
|
},
|
||||||
|
) == PlanData._from_pull(session, data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_plan_from_fetch(session):
|
||||||
|
data = {
|
||||||
|
"message_thread_id": 123456789,
|
||||||
|
"event_time": 1500000000,
|
||||||
|
"creator_id": 1234,
|
||||||
|
"event_time_updated_time": 1450000000,
|
||||||
|
"title": "abc",
|
||||||
|
"track_rsvp": 1,
|
||||||
|
"event_type": "EVENT",
|
||||||
|
"status": "created",
|
||||||
|
"message_id": "mid.xyz",
|
||||||
|
"seconds_to_notify_before": 3600,
|
||||||
|
"event_time_source": "user",
|
||||||
|
"repeat_mode": "once",
|
||||||
|
"creation_time": 1400000000,
|
||||||
|
"location_id": 0,
|
||||||
|
"location_name": None,
|
||||||
|
"latitude": "",
|
||||||
|
"longitude": "",
|
||||||
|
"event_id": 0,
|
||||||
|
"trigger_message_id": "",
|
||||||
|
"note": "",
|
||||||
|
"timezone_id": 0,
|
||||||
|
"end_time": 0,
|
||||||
|
"list_id": 0,
|
||||||
|
"payload_id": 0,
|
||||||
|
"cu_app": "",
|
||||||
|
"location_sharing_subtype": "",
|
||||||
|
"reminder_notif_param": [],
|
||||||
|
"workplace_meeting_id": "",
|
||||||
|
"genie_fbid": 0,
|
||||||
|
"galaxy": "",
|
||||||
|
"oid": 1111,
|
||||||
|
"type": 8128,
|
||||||
|
"is_active": True,
|
||||||
|
"location_address": None,
|
||||||
|
"event_members": {
|
||||||
|
"1234": "INVITED",
|
||||||
|
"2356": "INVITED",
|
||||||
|
"3456": "DECLINED",
|
||||||
|
"4567": "GOING",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
assert PlanData(
|
||||||
|
session=session,
|
||||||
|
id=1111,
|
||||||
|
time=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc),
|
||||||
|
title="abc",
|
||||||
|
location="",
|
||||||
|
location_id="",
|
||||||
|
author_id=1234,
|
||||||
|
guests={
|
||||||
|
"1234": GuestStatus.INVITED,
|
||||||
|
"2356": GuestStatus.INVITED,
|
||||||
|
"3456": GuestStatus.DECLINED,
|
||||||
|
"4567": GuestStatus.GOING,
|
||||||
|
},
|
||||||
|
) == PlanData._from_fetch(session, data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_plan_from_graphql(session):
|
||||||
|
data = {
|
||||||
|
"id": "1111",
|
||||||
|
"lightweight_event_creator": {"id": "1234"},
|
||||||
|
"time": 1500000000,
|
||||||
|
"lightweight_event_type": "EVENT",
|
||||||
|
"location_name": None,
|
||||||
|
"location_coordinates": None,
|
||||||
|
"location_page": None,
|
||||||
|
"lightweight_event_status": "CREATED",
|
||||||
|
"note": "",
|
||||||
|
"repeat_mode": "ONCE",
|
||||||
|
"event_title": "abc",
|
||||||
|
"trigger_message": None,
|
||||||
|
"seconds_to_notify_before": 3600,
|
||||||
|
"allows_rsvp": True,
|
||||||
|
"related_event": None,
|
||||||
|
"event_reminder_members": {
|
||||||
|
"edges": [
|
||||||
|
{"node": {"id": "1234"}, "guest_list_state": "INVITED"},
|
||||||
|
{"node": {"id": "2356"}, "guest_list_state": "INVITED"},
|
||||||
|
{"node": {"id": "3456"}, "guest_list_state": "DECLINED"},
|
||||||
|
{"node": {"id": "4567"}, "guest_list_state": "GOING"},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
assert PlanData(
|
||||||
|
session=session,
|
||||||
|
time=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc),
|
||||||
|
title="abc",
|
||||||
|
location="",
|
||||||
|
location_id="",
|
||||||
|
id="1111",
|
||||||
|
author_id="1234",
|
||||||
|
guests={
|
||||||
|
"1234": GuestStatus.INVITED,
|
||||||
|
"2356": GuestStatus.INVITED,
|
||||||
|
"3456": GuestStatus.DECLINED,
|
||||||
|
"4567": GuestStatus.GOING,
|
||||||
|
},
|
||||||
|
) == PlanData._from_graphql(session, data)
|
||||||
94
tests/models/test_poll.py
Normal file
94
tests/models/test_poll.py
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
from fbchat import Poll, PollOption
|
||||||
|
|
||||||
|
|
||||||
|
def test_poll_option_from_graphql_unvoted():
|
||||||
|
data = {
|
||||||
|
"id": "123456789",
|
||||||
|
"text": "abc",
|
||||||
|
"total_count": 0,
|
||||||
|
"viewer_has_voted": "false",
|
||||||
|
"voters": [],
|
||||||
|
}
|
||||||
|
assert PollOption(
|
||||||
|
text="abc", vote=False, voters=[], votes_count=0, id="123456789"
|
||||||
|
) == PollOption._from_graphql(data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_poll_option_from_graphql_voted():
|
||||||
|
data = {
|
||||||
|
"id": "123456789",
|
||||||
|
"text": "abc",
|
||||||
|
"total_count": 2,
|
||||||
|
"viewer_has_voted": "true",
|
||||||
|
"voters": ["1234", "2345"],
|
||||||
|
}
|
||||||
|
assert PollOption(
|
||||||
|
text="abc", vote=True, voters=["1234", "2345"], votes_count=2, id="123456789"
|
||||||
|
) == PollOption._from_graphql(data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_poll_option_from_graphql_alternate_format():
|
||||||
|
# Format received when fetching poll options
|
||||||
|
data = {
|
||||||
|
"id": "123456789",
|
||||||
|
"text": "abc",
|
||||||
|
"viewer_has_voted": True,
|
||||||
|
"voters": {
|
||||||
|
"count": 2,
|
||||||
|
"edges": [{"node": {"id": "1234"}}, {"node": {"id": "2345"}}],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
assert PollOption(
|
||||||
|
text="abc", vote=True, voters=["1234", "2345"], votes_count=2, id="123456789"
|
||||||
|
) == PollOption._from_graphql(data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_poll_from_graphql(session):
|
||||||
|
data = {
|
||||||
|
"id": "123456789",
|
||||||
|
"text": "Some poll",
|
||||||
|
"total_count": 5,
|
||||||
|
"viewer_has_voted": "true",
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"id": "1111",
|
||||||
|
"text": "Abc",
|
||||||
|
"total_count": 1,
|
||||||
|
"viewer_has_voted": "true",
|
||||||
|
"voters": ["1234"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "2222",
|
||||||
|
"text": "Def",
|
||||||
|
"total_count": 2,
|
||||||
|
"viewer_has_voted": "false",
|
||||||
|
"voters": ["2345", "3456"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "3333",
|
||||||
|
"text": "Ghi",
|
||||||
|
"total_count": 0,
|
||||||
|
"viewer_has_voted": "false",
|
||||||
|
"voters": [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
assert Poll(
|
||||||
|
session=session,
|
||||||
|
question="Some poll",
|
||||||
|
options=[
|
||||||
|
PollOption(
|
||||||
|
text="Abc", vote=True, voters=["1234"], votes_count=1, id="1111"
|
||||||
|
),
|
||||||
|
PollOption(
|
||||||
|
text="Def",
|
||||||
|
vote=False,
|
||||||
|
voters=["2345", "3456"],
|
||||||
|
votes_count=2,
|
||||||
|
id="2222",
|
||||||
|
),
|
||||||
|
PollOption(text="Ghi", vote=False, voters=[], votes_count=0, id="3333"),
|
||||||
|
],
|
||||||
|
options_count=5,
|
||||||
|
id=123456789,
|
||||||
|
) == Poll._from_graphql(session, data)
|
||||||
49
tests/models/test_quick_reply.py
Normal file
49
tests/models/test_quick_reply.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
from fbchat import (
|
||||||
|
QuickReplyText,
|
||||||
|
QuickReplyLocation,
|
||||||
|
QuickReplyPhoneNumber,
|
||||||
|
QuickReplyEmail,
|
||||||
|
)
|
||||||
|
from fbchat._models._quick_reply import graphql_to_quick_reply
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_minimal():
|
||||||
|
data = {
|
||||||
|
"content_type": "text",
|
||||||
|
"payload": None,
|
||||||
|
"external_payload": None,
|
||||||
|
"data": None,
|
||||||
|
"title": "A",
|
||||||
|
"image_url": None,
|
||||||
|
}
|
||||||
|
assert QuickReplyText(title="A") == graphql_to_quick_reply(data)
|
||||||
|
data = {"content_type": "location"}
|
||||||
|
assert QuickReplyLocation() == graphql_to_quick_reply(data)
|
||||||
|
data = {"content_type": "user_phone_number"}
|
||||||
|
assert QuickReplyPhoneNumber() == graphql_to_quick_reply(data)
|
||||||
|
data = {"content_type": "user_email"}
|
||||||
|
assert QuickReplyEmail() == graphql_to_quick_reply(data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_text_full():
|
||||||
|
data = {
|
||||||
|
"content_type": "text",
|
||||||
|
"title": "A",
|
||||||
|
"payload": "Some payload",
|
||||||
|
"image_url": "https://example.com/image.jpg",
|
||||||
|
"data": None,
|
||||||
|
}
|
||||||
|
assert QuickReplyText(
|
||||||
|
payload="Some payload",
|
||||||
|
data=None,
|
||||||
|
is_response=False,
|
||||||
|
title="A",
|
||||||
|
image_url="https://example.com/image.jpg",
|
||||||
|
) == graphql_to_quick_reply(data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_with_is_response():
|
||||||
|
data = {"content_type": "text"}
|
||||||
|
assert QuickReplyText(is_response=True) == graphql_to_quick_reply(
|
||||||
|
data, is_response=True
|
||||||
|
)
|
||||||
91
tests/models/test_sticker.py
Normal file
91
tests/models/test_sticker.py
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import pytest
|
||||||
|
import fbchat
|
||||||
|
from fbchat import Image, Sticker
|
||||||
|
|
||||||
|
|
||||||
|
def test_from_graphql_none():
|
||||||
|
assert None == Sticker._from_graphql(None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_from_graphql_minimal():
|
||||||
|
assert Sticker(id=1) == Sticker._from_graphql({"id": 1})
|
||||||
|
|
||||||
|
|
||||||
|
def test_from_graphql_normal():
|
||||||
|
assert Sticker(
|
||||||
|
id="369239383222810",
|
||||||
|
pack="227877430692340",
|
||||||
|
is_animated=False,
|
||||||
|
frames_per_row=1,
|
||||||
|
frames_per_col=1,
|
||||||
|
frame_count=1,
|
||||||
|
frame_rate=83,
|
||||||
|
image=Image(
|
||||||
|
url="https://scontent-arn2-1.xx.fbcdn.net/v/redacted.png",
|
||||||
|
width=274,
|
||||||
|
height=274,
|
||||||
|
),
|
||||||
|
label="Like, thumbs up",
|
||||||
|
) == Sticker._from_graphql(
|
||||||
|
{
|
||||||
|
"id": "369239383222810",
|
||||||
|
"pack": {"id": "227877430692340"},
|
||||||
|
"label": "Like, thumbs up",
|
||||||
|
"frame_count": 1,
|
||||||
|
"frame_rate": 83,
|
||||||
|
"frames_per_row": 1,
|
||||||
|
"frames_per_column": 1,
|
||||||
|
"sprite_image_2x": None,
|
||||||
|
"sprite_image": None,
|
||||||
|
"padded_sprite_image": None,
|
||||||
|
"padded_sprite_image_2x": None,
|
||||||
|
"url": "https://scontent-arn2-1.xx.fbcdn.net/v/redacted.png",
|
||||||
|
"height": 274,
|
||||||
|
"width": 274,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_from_graphql_animated():
|
||||||
|
assert Sticker(
|
||||||
|
id="144885035685763",
|
||||||
|
pack="350357561732812",
|
||||||
|
is_animated=True,
|
||||||
|
medium_sprite_image="https://scontent-arn2-1.xx.fbcdn.net/v/redacted2.png",
|
||||||
|
large_sprite_image="https://scontent-arn2-1.fbcdn.net/v/redacted3.png",
|
||||||
|
frames_per_row=2,
|
||||||
|
frames_per_col=2,
|
||||||
|
frame_count=4,
|
||||||
|
frame_rate=142,
|
||||||
|
image=Image(
|
||||||
|
url="https://scontent-arn2-1.fbcdn.net/v/redacted1.png",
|
||||||
|
width=240,
|
||||||
|
height=293,
|
||||||
|
),
|
||||||
|
label="Love, cat with heart",
|
||||||
|
) == Sticker._from_graphql(
|
||||||
|
{
|
||||||
|
"id": "144885035685763",
|
||||||
|
"pack": {"id": "350357561732812"},
|
||||||
|
"label": "Love, cat with heart",
|
||||||
|
"frame_count": 4,
|
||||||
|
"frame_rate": 142,
|
||||||
|
"frames_per_row": 2,
|
||||||
|
"frames_per_column": 2,
|
||||||
|
"sprite_image_2x": {
|
||||||
|
"uri": "https://scontent-arn2-1.fbcdn.net/v/redacted3.png"
|
||||||
|
},
|
||||||
|
"sprite_image": {
|
||||||
|
"uri": "https://scontent-arn2-1.xx.fbcdn.net/v/redacted2.png"
|
||||||
|
},
|
||||||
|
"padded_sprite_image": {
|
||||||
|
"uri": "https://scontent-arn2-1.xx.fbcdn.net/v/unused1.png"
|
||||||
|
},
|
||||||
|
"padded_sprite_image_2x": {
|
||||||
|
"uri": "https://scontent-arn2-1.xx.fbcdn.net/v/unused2.png"
|
||||||
|
},
|
||||||
|
"url": "https://scontent-arn2-1.fbcdn.net/v/redacted1.png",
|
||||||
|
"height": 293,
|
||||||
|
"width": 240,
|
||||||
|
}
|
||||||
|
)
|
||||||
67
tests/online/conftest.py
Normal file
67
tests/online/conftest.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import fbchat
|
||||||
|
import pytest
|
||||||
|
import logging
|
||||||
|
import getpass
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def session(pytestconfig):
|
||||||
|
session_cookies = pytestconfig.cache.get("session_cookies", None)
|
||||||
|
try:
|
||||||
|
session = fbchat.Session.from_cookies(session_cookies)
|
||||||
|
except fbchat.FacebookError:
|
||||||
|
logging.exception("Error while logging in with cookies!")
|
||||||
|
session = fbchat.Session.login(input("Email: "), getpass.getpass("Password: "))
|
||||||
|
|
||||||
|
yield session
|
||||||
|
|
||||||
|
pytestconfig.cache.set("session_cookies", session.get_cookies())
|
||||||
|
|
||||||
|
# TODO: Allow the main session object to be closed - and perhaps used in `with`?
|
||||||
|
session._session.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(session):
|
||||||
|
return fbchat.Client(session=session)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def user(pytestconfig, session):
|
||||||
|
user_id = pytestconfig.cache.get("user_id", None)
|
||||||
|
if not user_id:
|
||||||
|
user_id = input("A user you're chatting with's id: ")
|
||||||
|
pytestconfig.cache.set("user_id", user_id)
|
||||||
|
return fbchat.User(session=session, id=user_id)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def group(pytestconfig, session):
|
||||||
|
group_id = pytestconfig.cache.get("group_id", None)
|
||||||
|
if not group_id:
|
||||||
|
group_id = input("A group you're chatting with's id: ")
|
||||||
|
pytestconfig.cache.set("group_id", group_id)
|
||||||
|
return fbchat.Group(session=session, id=group_id)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(
|
||||||
|
scope="session",
|
||||||
|
params=[
|
||||||
|
"user",
|
||||||
|
"group",
|
||||||
|
"self",
|
||||||
|
pytest.param("invalid", marks=[pytest.mark.xfail()]),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def any_thread(request, session, user, group):
|
||||||
|
return {
|
||||||
|
"user": user,
|
||||||
|
"group": group,
|
||||||
|
"self": session.user,
|
||||||
|
"invalid": fbchat.Thread(session=session, id="0"),
|
||||||
|
}[request.param]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def listener(session):
|
||||||
|
return fbchat.Listener(session=session, chat_on=False, foreground=False)
|
||||||
116
tests/online/test_client.py
Normal file
116
tests/online/test_client.py
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import pytest
|
||||||
|
import fbchat
|
||||||
|
import os
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.online
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch(client):
|
||||||
|
client.fetch_users()
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_for_users(client):
|
||||||
|
list(client.search_for_users("test", 10))
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_for_pages(client):
|
||||||
|
list(client.search_for_pages("test", 100))
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_for_groups(client):
|
||||||
|
list(client.search_for_groups("test", 1000))
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_for_threads(client):
|
||||||
|
list(client.search_for_threads("test", 1000))
|
||||||
|
|
||||||
|
with pytest.raises(fbchat.HTTPError, match="rate limited"):
|
||||||
|
list(client.search_for_threads("test", 10000))
|
||||||
|
|
||||||
|
|
||||||
|
def test_message_search(client):
|
||||||
|
list(client.search_messages("test", 500))
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_thread_info(client):
|
||||||
|
list(client.fetch_thread_info(["4"]))[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_threads(client):
|
||||||
|
list(client.fetch_threads(20))
|
||||||
|
list(client.fetch_threads(200))
|
||||||
|
|
||||||
|
|
||||||
|
def test_undocumented(client):
|
||||||
|
client.fetch_unread()
|
||||||
|
client.fetch_unseen()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def open_resource(pytestconfig):
|
||||||
|
def get_resource_inner(filename):
|
||||||
|
path = os.path.join(pytestconfig.rootdir, "tests", "resources", filename)
|
||||||
|
return open(path, "rb")
|
||||||
|
|
||||||
|
return get_resource_inner
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_and_fetch_image_url(client, open_resource):
|
||||||
|
with open_resource("image.png") as f:
|
||||||
|
((id, mimetype),) = client.upload([("image.png", f, "image/png")])
|
||||||
|
assert mimetype == "image/png"
|
||||||
|
|
||||||
|
assert client.fetch_image_url(id).startswith("http")
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_image(client, open_resource):
|
||||||
|
with open_resource("image.png") as f:
|
||||||
|
_ = client.upload([("image.png", f, "image/png")])
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_many(client, open_resource):
|
||||||
|
with open_resource("image.png") as f_png, open_resource(
|
||||||
|
"image.jpg"
|
||||||
|
) as f_jpg, open_resource("image.gif") as f_gif, open_resource(
|
||||||
|
"file.json"
|
||||||
|
) as f_json, open_resource(
|
||||||
|
"file.txt"
|
||||||
|
) as f_txt, open_resource(
|
||||||
|
"audio.mp3"
|
||||||
|
) as f_mp3, open_resource(
|
||||||
|
"video.mp4"
|
||||||
|
) as f_mp4:
|
||||||
|
_ = client.upload(
|
||||||
|
[
|
||||||
|
("image.png", f_png, "image/png"),
|
||||||
|
("image.jpg", f_jpg, "image/jpeg"),
|
||||||
|
("image.gif", f_gif, "image/gif"),
|
||||||
|
("file.json", f_json, "application/json"),
|
||||||
|
("file.txt", f_txt, "text/plain"),
|
||||||
|
("audio.mp3", f_mp3, "audio/mpeg"),
|
||||||
|
("video.mp4", f_mp4, "video/mp4"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_mark_as_read(client, user, group):
|
||||||
|
client.mark_as_read([user, group], fbchat._util.now())
|
||||||
|
|
||||||
|
|
||||||
|
def test_mark_as_unread(client, user, group):
|
||||||
|
client.mark_as_unread([user, group], fbchat._util.now())
|
||||||
|
|
||||||
|
|
||||||
|
def test_move_threads(client, user, group):
|
||||||
|
client.move_threads(fbchat.ThreadLocation.PENDING, [user, group])
|
||||||
|
client.move_threads(fbchat.ThreadLocation.INBOX, [user, group])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skip(reason="need to have threads to delete")
|
||||||
|
def test_delete_threads():
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skip(reason="need to have messages to delete")
|
||||||
|
def test_delete_messages():
|
||||||
|
pass
|
||||||
42
tests/online/test_send.py
Normal file
42
tests/online/test_send.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import pytest
|
||||||
|
import fbchat
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.online
|
||||||
|
|
||||||
|
|
||||||
|
# TODO: Verify return values
|
||||||
|
|
||||||
|
|
||||||
|
def test_wave(any_thread):
|
||||||
|
assert any_thread.wave(True)
|
||||||
|
assert any_thread.wave(False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_text(any_thread):
|
||||||
|
assert any_thread.send_text("Test")
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_text_with_mention(any_thread):
|
||||||
|
mention = fbchat.Mention(thread_id=any_thread.id, offset=5, length=8)
|
||||||
|
assert any_thread.send_text("Test @mention", mentions=[mention])
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_emoji(any_thread):
|
||||||
|
assert any_thread.send_emoji("😀", size=fbchat.EmojiSize.LARGE)
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_sticker(any_thread):
|
||||||
|
assert any_thread.send_sticker("1889713947839631")
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_location(any_thread):
|
||||||
|
any_thread.send_location(51.5287718, -0.2416815)
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_pinned_location(any_thread):
|
||||||
|
any_thread.send_pinned_location(39.9390731, 116.117273)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skip(reason="need a way to use the uploaded files from test_client.py")
|
||||||
|
def test_send_files(any_thread):
|
||||||
|
pass
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
import py_compile
|
|
||||||
|
|
||||||
from glob import glob
|
|
||||||
from os import path, environ
|
|
||||||
from fbchat import Client
|
|
||||||
from fbchat.models import FBchatUserError, Message
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.offline
|
|
||||||
def test_examples():
|
|
||||||
# Compiles the examples, to check for syntax errors
|
|
||||||
for name in glob(path.join(path.dirname(__file__), "../examples", "*.py")):
|
|
||||||
py_compile.compile(name)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.trylast
|
|
||||||
@pytest.mark.expensive
|
|
||||||
def test_login(client1):
|
|
||||||
assert client1.isLoggedIn()
|
|
||||||
email = client1.email
|
|
||||||
password = client1.password
|
|
||||||
|
|
||||||
client1.logout()
|
|
||||||
|
|
||||||
assert not client1.isLoggedIn()
|
|
||||||
|
|
||||||
with pytest.raises(FBchatUserError):
|
|
||||||
client1.login("<invalid email>", "<invalid password>", max_tries=1)
|
|
||||||
|
|
||||||
client1.login(email, password)
|
|
||||||
|
|
||||||
assert client1.isLoggedIn()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.trylast
|
|
||||||
def test_sessions(client1):
|
|
||||||
session = client1.getSession()
|
|
||||||
Client("no email needed", "no password needed", session_cookies=session)
|
|
||||||
client1.setSession(session)
|
|
||||||
assert client1.isLoggedIn()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.tryfirst
|
|
||||||
def test_default_thread(client1, thread):
|
|
||||||
client1.setDefaultThread(thread["id"], thread["type"])
|
|
||||||
assert client1.send(Message(text="Sent to the specified thread"))
|
|
||||||
|
|
||||||
client1.resetDefaultThread()
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
client1.send(Message(text="Should not be sent"))
|
|
||||||
10
tests/test_examples.py
Normal file
10
tests/test_examples.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import pytest
|
||||||
|
import py_compile
|
||||||
|
import glob
|
||||||
|
from os import path
|
||||||
|
|
||||||
|
|
||||||
|
def test_examples_compiles():
|
||||||
|
# Compiles the examples, to check for syntax errors
|
||||||
|
for name in glob.glob(path.join(path.dirname(__file__), "../examples", "*.py")):
|
||||||
|
py_compile.compile(name)
|
||||||
237
tests/test_exception.py
Normal file
237
tests/test_exception.py
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
import pytest
|
||||||
|
import requests
|
||||||
|
from fbchat import (
|
||||||
|
FacebookError,
|
||||||
|
HTTPError,
|
||||||
|
ParseError,
|
||||||
|
ExternalError,
|
||||||
|
GraphQLError,
|
||||||
|
InvalidParameters,
|
||||||
|
NotLoggedIn,
|
||||||
|
PleaseRefresh,
|
||||||
|
)
|
||||||
|
from fbchat._exception import (
|
||||||
|
handle_payload_error,
|
||||||
|
handle_graphql_errors,
|
||||||
|
handle_http_error,
|
||||||
|
handle_requests_error,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
ERROR_DATA = [
|
||||||
|
(
|
||||||
|
PleaseRefresh,
|
||||||
|
1357004,
|
||||||
|
"Sorry, something went wrong",
|
||||||
|
"Please try closing and re-opening your browser window.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
InvalidParameters,
|
||||||
|
1357031,
|
||||||
|
"This content is no longer available",
|
||||||
|
(
|
||||||
|
"The content you requested cannot be displayed at the moment. It may be"
|
||||||
|
" temporarily unavailable, the link you clicked on may have expired or you"
|
||||||
|
" may not have permission to view this page."
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
InvalidParameters,
|
||||||
|
1545010,
|
||||||
|
"Messages Unavailable",
|
||||||
|
(
|
||||||
|
"Sorry, messages are temporarily unavailable."
|
||||||
|
" Please try again in a few minutes."
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ExternalError,
|
||||||
|
1545026,
|
||||||
|
"Unable to Attach File",
|
||||||
|
(
|
||||||
|
"The type of file you're trying to attach isn't allowed."
|
||||||
|
" Please try again with a different format."
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(InvalidParameters, 1545003, "Invalid action", "You cannot perform that action."),
|
||||||
|
(
|
||||||
|
ExternalError,
|
||||||
|
1545012,
|
||||||
|
"Temporary Failure",
|
||||||
|
"There was a temporary error, please try again.",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("exception,code,summary,description", ERROR_DATA)
|
||||||
|
def test_handle_payload_error(exception, code, summary, description):
|
||||||
|
data = {"error": code, "errorSummary": summary, "errorDescription": description}
|
||||||
|
with pytest.raises(exception, match=r"#\d+ .+:"):
|
||||||
|
handle_payload_error(data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_handle_not_logged_in_error():
|
||||||
|
data = {
|
||||||
|
"error": 1357001,
|
||||||
|
"errorSummary": "Not logged in",
|
||||||
|
"errorDescription": "Please log in to continue.",
|
||||||
|
}
|
||||||
|
with pytest.raises(NotLoggedIn, match="Not logged in"):
|
||||||
|
handle_payload_error(data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_handle_payload_error_no_error():
|
||||||
|
assert handle_payload_error({}) is None
|
||||||
|
assert handle_payload_error({"payload": {"abc": ["Something", "else"]}}) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_handle_graphql_crash():
|
||||||
|
error = {
|
||||||
|
"allow_user_retry": False,
|
||||||
|
"api_error_code": -1,
|
||||||
|
"code": 1675030,
|
||||||
|
"debug_info": None,
|
||||||
|
"description": "Error performing query.",
|
||||||
|
"fbtrace_id": "ABCDEFG",
|
||||||
|
"is_silent": False,
|
||||||
|
"is_transient": False,
|
||||||
|
"message": (
|
||||||
|
'Errors while executing operation "MessengerThreadSharedLinks":'
|
||||||
|
" At Query.message_thread: Field implementation threw an exception."
|
||||||
|
" Check your server logs for more information."
|
||||||
|
),
|
||||||
|
"path": ["message_thread"],
|
||||||
|
"query_path": None,
|
||||||
|
"requires_reauth": False,
|
||||||
|
"severity": "CRITICAL",
|
||||||
|
"summary": "Query error",
|
||||||
|
}
|
||||||
|
with pytest.raises(
|
||||||
|
GraphQLError, match="#1675030 Query error: Errors while executing"
|
||||||
|
):
|
||||||
|
handle_graphql_errors({"data": {"message_thread": None}, "errors": [error]})
|
||||||
|
|
||||||
|
|
||||||
|
def test_handle_graphql_invalid_values():
|
||||||
|
error = {
|
||||||
|
"message": (
|
||||||
|
'Invalid values provided for variables of operation "MessengerThreadlist":'
|
||||||
|
' Value ""as"" cannot be used for variable "$limit": Expected an integer'
|
||||||
|
' value, got "as".'
|
||||||
|
),
|
||||||
|
"severity": "CRITICAL",
|
||||||
|
"code": 1675012,
|
||||||
|
"api_error_code": None,
|
||||||
|
"summary": "Your request couldn't be processed",
|
||||||
|
"description": (
|
||||||
|
"There was a problem with this request."
|
||||||
|
" We're working on getting it fixed as soon as we can."
|
||||||
|
),
|
||||||
|
"is_silent": False,
|
||||||
|
"is_transient": False,
|
||||||
|
"requires_reauth": False,
|
||||||
|
"allow_user_retry": False,
|
||||||
|
"debug_info": None,
|
||||||
|
"query_path": None,
|
||||||
|
"fbtrace_id": "ABCDEFG",
|
||||||
|
"www_request_id": "AABBCCDDEEFFGG",
|
||||||
|
}
|
||||||
|
msg = "#1675012 Your request couldn't be processed: Invalid values"
|
||||||
|
with pytest.raises(GraphQLError, match=msg):
|
||||||
|
handle_graphql_errors({"errors": [error]})
|
||||||
|
|
||||||
|
|
||||||
|
def test_handle_graphql_no_message():
|
||||||
|
error = {
|
||||||
|
"code": 1675012,
|
||||||
|
"api_error_code": None,
|
||||||
|
"summary": "Your request couldn't be processed",
|
||||||
|
"description": (
|
||||||
|
"There was a problem with this request."
|
||||||
|
" We're working on getting it fixed as soon as we can."
|
||||||
|
),
|
||||||
|
"is_silent": False,
|
||||||
|
"is_transient": False,
|
||||||
|
"requires_reauth": False,
|
||||||
|
"allow_user_retry": False,
|
||||||
|
"debug_info": None,
|
||||||
|
"query_path": None,
|
||||||
|
"fbtrace_id": "ABCDEFG",
|
||||||
|
"www_request_id": "AABBCCDDEEFFGG",
|
||||||
|
"sentry_block_user_info": None,
|
||||||
|
"help_center_id": None,
|
||||||
|
}
|
||||||
|
msg = "#1675012 Your request couldn't be processed: "
|
||||||
|
with pytest.raises(GraphQLError, match=msg):
|
||||||
|
handle_graphql_errors({"errors": [error]})
|
||||||
|
|
||||||
|
|
||||||
|
def test_handle_graphql_no_summary():
|
||||||
|
error = {
|
||||||
|
"message": (
|
||||||
|
'Errors while executing operation "MessengerViewerContactMethods":'
|
||||||
|
" At Query.viewer:Viewer.all_emails: Field implementation threw an"
|
||||||
|
" exception. Check your server logs for more information."
|
||||||
|
),
|
||||||
|
"severity": "ERROR",
|
||||||
|
"path": ["viewer", "all_emails"],
|
||||||
|
}
|
||||||
|
with pytest.raises(GraphQLError, match="Unknown error: Errors while executing"):
|
||||||
|
handle_graphql_errors(
|
||||||
|
{"data": {"viewer": {"user": None, "all_emails": []}}, "errors": [error]}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_handle_graphql_syntax_error():
|
||||||
|
error = {
|
||||||
|
"code": 1675001,
|
||||||
|
"api_error_code": None,
|
||||||
|
"summary": "Query Syntax Error",
|
||||||
|
"description": "Syntax error.",
|
||||||
|
"is_silent": True,
|
||||||
|
"is_transient": False,
|
||||||
|
"requires_reauth": False,
|
||||||
|
"allow_user_retry": False,
|
||||||
|
"debug_info": 'Unexpected ">" at character 328: Expected ")".',
|
||||||
|
"query_path": None,
|
||||||
|
"fbtrace_id": "ABCDEFG",
|
||||||
|
"www_request_id": "AABBCCDDEEFFGG",
|
||||||
|
"sentry_block_user_info": None,
|
||||||
|
"help_center_id": None,
|
||||||
|
}
|
||||||
|
msg = "#1675001 Query Syntax Error: "
|
||||||
|
with pytest.raises(GraphQLError, match=msg):
|
||||||
|
handle_graphql_errors({"response": None, "error": error})
|
||||||
|
|
||||||
|
|
||||||
|
def test_handle_graphql_errors_singular_error_key():
|
||||||
|
with pytest.raises(GraphQLError, match="#123"):
|
||||||
|
handle_graphql_errors({"error": {"code": 123}})
|
||||||
|
|
||||||
|
|
||||||
|
def test_handle_graphql_errors_no_error():
|
||||||
|
assert handle_graphql_errors({"data": {"message_thread": None}}) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_handle_http_error():
|
||||||
|
with pytest.raises(HTTPError):
|
||||||
|
handle_http_error(400)
|
||||||
|
with pytest.raises(HTTPError):
|
||||||
|
handle_http_error(500)
|
||||||
|
|
||||||
|
|
||||||
|
def test_handle_http_error_404_handling():
|
||||||
|
with pytest.raises(HTTPError, match="invalid id"):
|
||||||
|
handle_http_error(404)
|
||||||
|
|
||||||
|
|
||||||
|
def test_handle_http_error_no_error():
|
||||||
|
assert handle_http_error(200) is None
|
||||||
|
assert handle_http_error(302) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_handle_requests_error():
|
||||||
|
with pytest.raises(HTTPError, match="Connection error"):
|
||||||
|
handle_requests_error(requests.ConnectionError())
|
||||||
|
with pytest.raises(HTTPError, match="Requests error"):
|
||||||
|
handle_requests_error(requests.RequestException())
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from os import path
|
|
||||||
from fbchat.models import ThreadType, Message, Mention, EmojiSize, Sticker
|
|
||||||
from utils import subset, STICKER_LIST, EMOJI_LIST
|
|
||||||
|
|
||||||
|
|
||||||
def test_fetch_all_users(client1):
|
|
||||||
users = client1.fetchAllUsers()
|
|
||||||
assert len(users) > 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_fetch_thread_list(client1):
|
|
||||||
threads = client1.fetchThreadList(limit=2)
|
|
||||||
assert len(threads) == 2
|
|
||||||
|
|
||||||
|
|
||||||
def test_fetch_threads(client1):
|
|
||||||
threads = client1.fetchThreads(limit=2)
|
|
||||||
assert len(threads) == 2
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("emoji, emoji_size", EMOJI_LIST)
|
|
||||||
def test_fetch_message_emoji(client, emoji, emoji_size):
|
|
||||||
mid = client.sendEmoji(emoji, emoji_size)
|
|
||||||
message, = client.fetchThreadMessages(limit=1)
|
|
||||||
|
|
||||||
assert subset(
|
|
||||||
vars(message), uid=mid, author=client.uid, text=emoji, emoji_size=emoji_size
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("emoji, emoji_size", EMOJI_LIST)
|
|
||||||
def test_fetch_message_info_emoji(client, thread, emoji, emoji_size):
|
|
||||||
mid = client.sendEmoji(emoji, emoji_size)
|
|
||||||
message = client.fetchMessageInfo(mid, thread_id=thread["id"])
|
|
||||||
|
|
||||||
assert subset(
|
|
||||||
vars(message), uid=mid, author=client.uid, text=emoji, emoji_size=emoji_size
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_fetch_message_mentions(client, thread, message_with_mentions):
|
|
||||||
mid = client.send(message_with_mentions)
|
|
||||||
message, = client.fetchThreadMessages(limit=1)
|
|
||||||
|
|
||||||
assert subset(
|
|
||||||
vars(message), uid=mid, author=client.uid, text=message_with_mentions.text
|
|
||||||
)
|
|
||||||
# The mentions are not ordered by offset
|
|
||||||
for m in message.mentions:
|
|
||||||
assert vars(m) in [vars(x) for x in message_with_mentions.mentions]
|
|
||||||
|
|
||||||
|
|
||||||
def test_fetch_message_info_mentions(client, thread, message_with_mentions):
|
|
||||||
mid = client.send(message_with_mentions)
|
|
||||||
message = client.fetchMessageInfo(mid, thread_id=thread["id"])
|
|
||||||
|
|
||||||
assert subset(
|
|
||||||
vars(message), uid=mid, author=client.uid, text=message_with_mentions.text
|
|
||||||
)
|
|
||||||
# The mentions are not ordered by offset
|
|
||||||
for m in message.mentions:
|
|
||||||
assert vars(m) in [vars(x) for x in message_with_mentions.mentions]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("sticker", STICKER_LIST)
|
|
||||||
def test_fetch_message_sticker(client, sticker):
|
|
||||||
mid = client.send(Message(sticker=sticker))
|
|
||||||
message, = client.fetchThreadMessages(limit=1)
|
|
||||||
|
|
||||||
assert subset(vars(message), uid=mid, author=client.uid)
|
|
||||||
assert subset(vars(message.sticker), uid=sticker.uid)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("sticker", STICKER_LIST)
|
|
||||||
def test_fetch_message_info_sticker(client, thread, sticker):
|
|
||||||
mid = client.send(Message(sticker=sticker))
|
|
||||||
message = client.fetchMessageInfo(mid, thread_id=thread["id"])
|
|
||||||
|
|
||||||
assert subset(vars(message), uid=mid, author=client.uid)
|
|
||||||
assert subset(vars(message.sticker), uid=sticker.uid)
|
|
||||||
|
|
||||||
|
|
||||||
def test_fetch_info(client1, group):
|
|
||||||
info = client1.fetchUserInfo("4")["4"]
|
|
||||||
assert info.name == "Mark Zuckerberg"
|
|
||||||
|
|
||||||
info = client1.fetchGroupInfo(group["id"])[group["id"]]
|
|
||||||
assert info.type == ThreadType.GROUP
|
|
||||||
|
|
||||||
|
|
||||||
def test_fetch_image_url(client):
|
|
||||||
client.sendLocalFiles([path.join(path.dirname(__file__), "resources", "image.png")])
|
|
||||||
message, = client.fetchThreadMessages(limit=1)
|
|
||||||
|
|
||||||
assert client.fetchImageUrl(message.attachments[0].uid)
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user