Compare commits

..

103 Commits

Author SHA1 Message Date
Mads Marquart
e1e988272b Version up, thanks to @kapi2289
- Removed / privatized a lot of undocumented functionality, which might break some users application if they were relying on those (hence the minor version bump). See #432 for further reasoning
- Add `Client.forwardAttachment` and `Message.forwarded` attribute, see #420
- Fix parsing subattachments with no target, see #412
- Lots of other refactoring, aka. work on the transition towards v2
2019-06-23 18:02:05 +02:00
Mads Marquart
b159f04a6b Merge pull request #432 from carpedm20/undocumented-breaking-changes
Undocumented breaking changes
2019-06-23 17:51:37 +02:00
Mads Marquart
d91a7ea9e3 Remove internal stuff from _graphql in __init__.py 2019-06-23 17:44:03 +02:00
Mads Marquart
8056f3399e Privatize Client.req_url
Undocumented
2019-06-23 17:40:20 +02:00
Mads Marquart
fd9aa7ee90 Remove client.py
Undocumented
2019-06-23 17:38:42 +02:00
Mads Marquart
53c19f473b Remove utils.py
The file, and contained functions, were explicitly documented as something you couldn't rely on for backwards compatibility
2019-06-23 17:38:27 +02:00
Mads Marquart
78b5f05729 Fix search when the result is empty
Return an empty result instead of raising
2019-05-01 23:50:23 +02:00
Mads Marquart
f689376830 Fix check_json
The payload is sometimes a list
2019-05-01 23:49:29 +02:00
Mads Marquart
d244856b41 Fix setting forwarded when creating Message objects 2019-05-01 23:18:12 +02:00
Kacper Ziubryniewicz
3cd0f3a9a7 Add missing mid parameter in message parsing (#426) 2019-05-01 19:32:04 +02:00
Mads Marquart
f480d68b57 Fix subattachment target parsing, fixes #412 2019-04-25 23:26:04 +02:00
Mads Marquart
db2bda1f9b Fix ShareAttachment GraphQL source parsing
Fixes #418
2019-04-25 23:26:03 +02:00
Kacper Ziubryniewicz
f834c01921 Fix sending remote files with URLs containing GET parameters or hashtags (#423) 2019-04-25 22:50:32 +02:00
Mads Marquart
f945fa80b3 Merge pull request #419 from carpedm20/remove-client-variables
Clean up `Client` variables
2019-04-25 22:45:45 +02:00
Kacper Ziubryniewicz
70faa86e34 Add forwarding attachments (#420)
- Add ability to forward attachments and `Message.forwarded` attribute
- Improve error handling for a lot of client methods, including, but not limited to:
    - `fetchAllUsers`
    - `searchForMessageIDs`
    - `search`
    - `fetchThreadInfo` and siblings
    - `fetchUnread`
    - `fetchUnseen`
    - `fetchPollOptions`
    - `fetchPlanInfo`
    - `send` and siblings
    - File uploads
2019-04-25 22:03:03 +02:00
Mads Marquart
61502ed32a Merge pull request #406 from carpedm20/refactor-model-parsing
Refactor model parsing
2019-04-25 21:06:26 +02:00
Mads Marquart
bfca20bb12 Privatize req_counter, payloadDefault and seq client variables
These have complicated semantics, and so are hopefully not depended on externally
2019-04-17 23:24:01 +02:00
Mads Marquart
0fd86d05a1 Privatize sticky and pool client variables
These have complicated semantics, and so are hopefully not depended on externally
2019-04-17 23:22:54 +02:00
Mads Marquart
c688d64062 Make Client.uid read-only
Modifying `uid` was previously documented as giving undefined behaviour, now it'll throw an error
2019-04-17 23:22:23 +02:00
Mads Marquart
2f973f129d Privatize default_thread_X client variables
We have a setter method for them, so there should be no need to access these directly!
2019-04-17 23:20:08 +02:00
Mads Marquart
9b81365b0a Privatize fb_h and client_id variables
These are sparsely used and badly named, so probably not externally depended on externally
2019-04-17 23:20:08 +02:00
Mads Marquart
a079797fca Remove email/password client variables 2019-04-17 23:18:37 +02:00
Mads Marquart
6ab298f6e8 Remove temporary _postLogin variables from the client 2019-04-17 23:18:37 +02:00
Mads Marquart
a159999879 Remove redundant client variables 2019-04-17 23:18:37 +02:00
Mads Marquart
a71835a5b8 Version up, thanks to @kapi2289 and @LukasOndrejka 2019-04-17 22:58:37 +02:00
Mads Marquart
86a6e07804 Merge branch 'master' into refactor-model-parsing 2019-04-17 22:42:20 +02:00
Kacper Ziubryniewicz
73c6be1969 Add message parameter to sending location (#416) 2019-04-17 22:35:04 +02:00
Kacper Ziubryniewicz
7db7868d2b Fix forwarding replied messages (#417) 2019-04-17 22:34:19 +02:00
Mads Marquart
18ec1f5680 Merge branch 'master' into refactor-model-parsing 2019-04-17 22:06:07 +02:00
Mads Marquart
8e65074b11 Hotfixes
Fix mistakes introduced in #415
2019-04-17 21:56:50 +02:00
Mads Marquart
d720438aef Merge pull request #415 from carpedm20/cleanup
Cleanup
2019-04-17 21:11:18 +02:00
Mads Marquart
ec0e3a91d1 Merge branch 'master' into cleanup 2019-04-17 20:50:08 +02:00
Mads Marquart
48e7203ca6 Rename internal variable 2019-04-17 20:47:49 +02:00
Mads Marquart
4f76b79629 Merge pull request #409 from kapi2289/message-reply
Add replying to messages
2019-04-17 20:44:30 +02:00
Mads Marquart
1eeae78a9f Small refactoring
The `muteX` methods return values are now checked using `check_request`, `seq` is now parsed in `_parseMessage` and a few other things.
2019-03-31 00:16:36 +01:00
Mads Marquart
bc27f756ed Split long strings, use format when creating strings 2019-03-31 00:11:20 +01:00
Mads Marquart
6302d5fb8b Split overly nested calls 2019-03-31 00:07:29 +01:00
Mads Marquart
24e238c425 Remove superfluous whitespace 2019-03-31 00:02:49 +01:00
Mads Marquart
070f57fcc4 Refactor _graphql away 2019-03-30 21:20:20 +01:00
Kacper Ziubryniewicz
a4ce45e9b0 Add detecting replied messages while listening 2019-03-29 21:09:19 +01:00
Kacper Ziubryniewicz
a3efa7702a Add possibility to reply to messages
and to (partly) fetch the replied messages
2019-03-23 21:26:43 +01:00
LukasOndrejka
d7a5d00439 Add new colors (#393)
Color names are from https://www.htmlcsscolor.com/
2019-03-12 18:15:11 +01:00
Mads Marquart
6636d49cc0 Remove graphql.py 2019-03-10 20:30:22 +01:00
Mads Marquart
8e6ee4636e Move gender dict into _user.py 2019-03-10 20:25:02 +01:00
Mads Marquart
71f19dd3c7 Move fetchAllUsers parsing into User._from_all_fetch 2019-03-10 20:22:56 +01:00
Mads Marquart
e166b472c5 Move message pull parsing into Message._from_pull 2019-03-10 20:10:19 +01:00
Mads Marquart
28c867a115 Simplify _graphql.py imports 2019-03-10 19:54:36 +01:00
Mads Marquart
f20a04b2a0 Move graphql_to_message -> Message._from_graphql 2019-03-10 19:50:06 +01:00
Mads Marquart
1f961b2ca7 Move thread parser dispatching into _client.py
I know, it's not pretty, but it doesn't belong in _graphql.py either
2019-03-10 19:39:22 +01:00
Mads Marquart
e579e0c767 Move graphql_to_quick_reply into _quick_reply.py 2019-03-10 19:35:07 +01:00
Mads Marquart
6693ec9c36 Move graphql_to_extensible_attachment into _message.py 2019-03-10 19:33:58 +01:00
Mads Marquart
53856a3622 Move attachment and subattachment dispatching to _file.py 2019-03-10 19:26:01 +01:00
Mads Marquart
0b99238676 Move subattachment parsing to the respective models 2019-03-10 19:19:50 +01:00
Mads Marquart
cb2c68e25a Move graphql_to_page -> Page._from_graphql 2019-03-10 17:36:41 +01:00
Mads Marquart
fd5553a9f5 Move graphql_to_group -> Group._from_graphql 2019-03-10 17:33:46 +01:00
Mads Marquart
60ebbd87d8 Move graphql_to_thread user parsing to User._from_thread_fetch 2019-03-10 17:26:47 +01:00
Mads Marquart
3a5185fcc8 Move graphql_to_user -> User._from_graphql 2019-03-10 17:21:06 +01:00
Mads Marquart
ce469d5e5a Move get_customization_info -> Thread._parse_customization_info 2019-03-10 17:02:03 +01:00
Mads Marquart
4f0f126e48 Make Github Releases deploy in the published state 2019-03-10 16:45:16 +01:00
Mads Marquart
94c30a2440 Merge pull request #405 from carpedm20/private-api
Privatize `client`, `utils` and `graphql` submodules
2019-03-10 16:42:48 +01:00
Mads Marquart
1460b2f421 Version up, thanks to @oneblue and @darylkell 2019-03-10 16:33:44 +01:00
Mads Marquart
968223690e Move plan parsing to the Plan model
- Add `GuestStatus` enum
- Add `Plan.guests` property
- Made `Plan.going`, `Plan.declined` and `Plan.invited` property accessors to `Plan.guests`
2019-03-10 16:21:22 +01:00
Mads Marquart
789d9d8ca1 Split graphql_to_attachment into smaller methods 2019-03-07 21:22:56 +01:00
Mads Marquart
2ce99a2c44 Split graphql_to_extensible_attachment into smaller methods 2019-03-07 20:50:14 +01:00
Mads Marquart
ee207e994f Move graphql_to_live_location -> LiveLocationAttachment._from_pull 2019-03-07 20:17:29 +01:00
Mads Marquart
c374aca890 Fix _util exception import 2019-03-07 19:59:25 +01:00
Mads Marquart
c28ca58537 Add missing attributes to Poll and PollOption __init__ 2019-03-07 19:58:24 +01:00
Mads Marquart
0578ea2c3c Move graphql_to_poll -> Poll._from_graphql 2019-03-07 19:52:29 +01:00
Mads Marquart
e51ce99c1a Move graphql_to_poll_option -> PollOption._from_graphql 2019-03-07 19:47:36 +01:00
Mads Marquart
3440039610 Move graphql_to_sticker -> Sticker._from_graphql 2019-03-07 19:07:00 +01:00
Mads Marquart
279f637c75 Move graphql_color_to_enum -> ThreadColor._from_graphql 2019-03-07 18:54:38 +01:00
Mads Marquart
d940b64517 Move enum_extend_if_invalid -> Enum._extend_if_invalid 2019-03-07 18:42:58 +01:00
Mads Marquart
403870e39e Move emojisize pull parsing into the model 2019-03-07 18:13:05 +01:00
Mads Marquart
0383d613e6 Move ActiveStatus pull parsing into the model 2019-03-07 18:12:37 +01:00
Mads Marquart
40e9825ee0 Add deprecated public graphql module 2019-02-24 23:21:17 +01:00
Mads Marquart
ab9ca94181 Rename graphql.py -> _graphql.py 2019-02-24 23:17:36 +01:00
Mads Marquart
0f99a23af7 Add deprecated public utils module 2019-02-24 23:16:40 +01:00
Mads Marquart
bc5163adaf Rename utils.py -> _util.py 2019-02-24 23:15:12 +01:00
Mads Marquart
0561718917 Import utils.py and graphql.py directly into the global module 2019-02-24 23:08:23 +01:00
Mads Marquart
c1861627fb Make deprecated public client module 2019-02-24 23:06:29 +01:00
Mads Marquart
e5eccab871 Rename client.py -> _client.py 2019-02-24 23:01:26 +01:00
Mads Marquart
27f76ba659 Merge pull request #400 from carpedm20/pull-delta-refactor
Move pull delta parsing into separate method
2019-02-24 20:52:26 +01:00
Mads Marquart
589117b9e7 Move pull delta parsing into _parseDelta (commit 2) 2019-02-24 20:45:44 +01:00
Mads Marquart
80300cd160 Move pull delta parsing into _parseDelta (commit 1) 2019-02-24 20:45:01 +01:00
Mads Marquart
76171408cc Merge pull request #399 from carpedm20/attrs
Use attrs to declare our models
2019-02-24 20:24:21 +01:00
Mads Marquart
c1800a174f Update minimum attrs version 2019-02-24 20:18:11 +01:00
Mads Marquart
8ae8435940 Use attrs, to remove verbose __init__ and __repr__ methods
Backwards compatibility is strictly preserved in `__init__`, including parameter names, defaults and position. Whenever that's difficult using `attrs`, the custom `__init__` is kept instead (for the time being).

`__repr__` methods have changed to the format `attrs` use, but people don't rely on this for anything other than debug output, so it shouldn't be a problem.
2019-02-24 20:18:07 +01:00
Mads Marquart
f916cb3b53 Add attrs as dependency 2019-02-24 20:18:04 +01:00
Mads Marquart
929c2137bf Move model docstrings into the class level, out of init 2019-02-24 20:18:00 +01:00
Mads Marquart
98056e91c5 Split models.py into several files (#398)
* Move exception models into separate file
* Move thread model into separate file
* Move user model into separate file
* Move group and room models into separate file
* Move page model into separate file
* Move message model into separate file
* Move basic attachment models into separate file
* Move sticker model into separate file
* Move location models into separate file
* Move file attachment models into separate file
* Move mention model to reside with message model
* Move quick reply models into separate file
* Move poll models into separate file
* Move plan model into separate file
* Move active status model to reside with user model
* Move core enum model into separate file
* Move thread-related enums to reside with thread model
* Move typingstatus model to reside with user model
* Move emojisize and reaction enums to reside wtih message model
2019-02-24 20:06:59 +01:00
Mads Marquart
944a7248c3 Disable travis email notifications 2019-02-24 02:17:03 +01:00
darylkell
caa2ecd0b7 Fix LocationAttachment (#395)
Set `LocationAttachment.address` instead of `latitude` and `longitude`, when no GPS coords are supplied. Fixes #392
2019-02-19 12:19:20 +01:00
Blue
dfc2d0652f Make fetchUnread and fetchUnseen include group chats (#394)
* Correct fetchUnread and fetchUnseen to include 1:1 chats and group chats
2019-02-18 22:37:16 +01:00
Mads Marquart
8d25540445 Version up, thanks to @kapi2289 2019-02-03 22:07:44 +01:00
Mads Marquart
6ea174bfd4 Merge pull request #389 from kapi2289/fix-388
Fix #388 issue
2019-02-03 22:06:26 +01:00
Kacper Ziubryniewicz
56e43aec0e Apply suggestions and fixes from review 2019-02-03 19:03:43 +01:00
Kacper Ziubryniewicz
491d120c25 Fix #388 issue 2019-02-03 14:45:10 +01:00
Mads Marquart
82d071d52c Version up 2019-01-31 21:27:04 +01:00
Mads Marquart
8190654a91 Add section about black in CONTRIBUTING.rst 2019-01-31 21:09:15 +01:00
Mads Marquart
5e21702d16 Add black code style badge 2019-01-31 21:00:17 +01:00
Mads Marquart
3df4172237 Add travis format checking step 2019-01-31 20:59:48 +01:00
Mads Marquart
e0710a2ec1 Format strings using black 2019-01-31 20:55:22 +01:00
Mads Marquart
d20fc3b9ce Format using black (without string normalization) 2019-01-31 20:54:32 +01:00
40 changed files with 4281 additions and 2824 deletions

View File

@@ -5,6 +5,7 @@ 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
@@ -23,6 +24,11 @@ jobs:
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
@@ -34,7 +40,7 @@ jobs:
file_glob: true
file: dist/*
skip_cleanup: true
draft: true
draft: false
on:
tags: true
@@ -48,3 +54,8 @@ jobs:
script: flit publish
on:
tags: true
notifications:
email:
on_success: never
on_failure: change

View File

@@ -9,6 +9,22 @@ That means that if you're submitting a breaking change, it will probably take a
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
-----------------------
You can use `flit` to install the package as a symlink:
.. code-block::
$ # *nix:
$ flit install --symlink
$ # Windows:
$ flit install --pth-file
After that, you can ``import`` the module as normal.
Before committing, you should run ``black .`` in the main directory, to format your code.
Testing Environment
-------------------

View File

@@ -17,6 +17,10 @@ fbchat: Facebook Chat (Messenger) for Python
:target: https://travis-ci.org/carpedm20/fbchat
:alt: Travis CI
.. image:: https://img.shields.io/badge/code%20style-black-000000.svg
: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>`__.

View File

@@ -31,14 +31,3 @@ A good tip is to write ``from fbchat.models import *`` at the start of your sour
.. automodule:: fbchat.models
:members:
:undoc-members:
.. _api_utils:
Utils
-----
These functions and values are used internally by fbchat, and are subject to change. Do **NOT** rely on these to be backwards compatible!
.. automodule:: fbchat.utils
:members:

View File

@@ -19,7 +19,8 @@
import os
import sys
sys.path.insert(0, os.path.abspath('..'))
sys.path.insert(0, os.path.abspath(".."))
import fbchat
import tests
@@ -36,27 +37,27 @@ from fbchat import __copyright__, __author__, __version__, __description__
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones.
extensions = [
'sphinx.ext.autodoc',
'sphinx.ext.intersphinx',
'sphinx.ext.todo',
'sphinx.ext.viewcode'
"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']
templates_path = ["_templates"]
# The suffix(es) of source filenames.
# You can specify multiple suffix as a list of string:
#
# source_suffix = ['.rst', '.md']
source_suffix = '.rst'
source_suffix = ".rst"
# The master toctree document.
master_doc = 'index'
master_doc = "index"
# General information about the project.
project = 'fbchat'
title = 'fbchat Documentation'
project = "fbchat"
title = "fbchat Documentation"
copyright = __copyright__
author = __author__
description = __description__
@@ -80,10 +81,10 @@ language = None
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
# This patterns also effect to html_static_path and html_extra_path
exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
# The name of the Pygments (syntax highlighting) style to use.
pygments_style = 'sphinx'
pygments_style = "sphinx"
# If true, `todo` and `todoList` produce output, else they produce nothing.
todo_include_todos = True
@@ -95,7 +96,7 @@ todo_include_todos = True
# a list of builtin themes.
#
html_theme = 'alabaster'
html_theme = "alabaster"
# Theme options are theme-specific and customize the look and feel of a theme
# further. For a list of options available for each theme, see the
@@ -106,13 +107,13 @@ html_theme = 'alabaster'
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = ['_static']
html_static_path = ["_static"]
# -- Options for HTMLHelp output ------------------------------------------
# Output file base name for HTML help builder.
htmlhelp_basename = project + 'doc'
htmlhelp_basename = project + "doc"
# -- Options for LaTeX output ---------------------------------------------
@@ -121,15 +122,12 @@ latex_elements = {
# The paper size ('letterpaper' or 'a4paper').
#
# 'papersize': 'letterpaper',
# The font size ('10pt', '11pt' or '12pt').
#
# 'pointsize': '10pt',
# Additional stuff for the LaTeX preamble.
#
# 'preamble': '',
# Latex figure (float) alignment
#
# 'figure_align': 'htbp',
@@ -138,20 +136,14 @@ latex_elements = {
# Grouping the document tree into LaTeX files. List of tuples
# (source start file, target name, title,
# author, documentclass [howto, manual, or own class]).
latex_documents = [
(master_doc, project + '.tex', title,
author, 'manual'),
]
latex_documents = [(master_doc, project + ".tex", title, author, "manual")]
# -- Options for manual page output ---------------------------------------
# One entry per manual page. List of tuples
# (source start file, name, description, authors, manual section).
man_pages = [
(master_doc, project, title,
[author], 1)
]
man_pages = [(master_doc, project, title, [author], 1)]
# -- Options for Texinfo output -------------------------------------------
@@ -160,32 +152,27 @@ man_pages = [
# (source start file, target name, title, author,
# dir menu entry, description, category)
texinfo_documents = [
(master_doc, project, title,
author, project, description,
'Miscellaneous'),
(master_doc, project, title, author, project, description, "Miscellaneous")
]
# Example configuration for intersphinx: refer to the Python standard library.
intersphinx_mapping = {'https://docs.python.org/3/': None}
intersphinx_mapping = {"https://docs.python.org/3/": None}
add_function_parentheses = False
html_theme_options = {
'show_powered_by': False,
'github_user': 'carpedm20',
'github_repo': project,
'github_banner': True,
'show_related': False
"show_powered_by": False,
"github_user": "carpedm20",
"github_repo": project,
"github_banner": True,
"show_related": False,
}
html_sidebars = {
'**': ['sidebar.html', 'searchbox.html']
}
html_sidebars = {"**": ["sidebar.html", "searchbox.html"]}
html_show_sphinx = False
html_show_sourcelink = False
autoclass_content = 'init'
autoclass_content = "both"
html_short_title = description

View File

@@ -3,10 +3,10 @@
from fbchat import Client
from fbchat.models import *
client = Client('<email>', '<password>')
client = Client("<email>", "<password>")
print('Own id: {}'.format(client.uid))
print("Own id: {}".format(client.uid))
client.send(Message(text='Hi me!'), thread_id=client.uid, thread_type=ThreadType.USER)
client.send(Message(text="Hi me!"), thread_id=client.uid, thread_type=ThreadType.USER)
client.logout()

View File

@@ -14,5 +14,6 @@ class EchoBot(Client):
if author_id != self.uid:
self.send(message_object, thread_id=thread_id, thread_type=thread_type)
client = EchoBot("<email>", "<password>")
client.listen()

View File

@@ -3,7 +3,7 @@
from fbchat import Client
from fbchat.models import *
client = Client('<email>', '<password>')
client = Client("<email>", "<password>")
# Fetches a list of all users you're currently chatting with, as `User` objects
users = client.fetchAllUsers()
@@ -13,9 +13,9 @@ 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
user = client.fetchUserInfo('<user id>')['<user id>']
user = client.fetchUserInfo("<user id>")["<user id>"]
# 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.fetchUserInfo("<1st user id>", "<2nd user id>", "<3rd user id>")
print("user's name: {}".format(user.name))
print("users' names: {}".format([users[k].name for k in users]))
@@ -23,9 +23,9 @@ print("users' names: {}".format([users[k].name for k in users]))
# `searchForUsers` 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:
user = client.searchForUsers('<name of user>')[0]
user = client.searchForUsers("<name of user>")[0]
print('user ID: {}'.format(user.uid))
print("user ID: {}".format(user.uid))
print("user's name: {}".format(user.name))
print("user's photo: {}".format(user.photo))
print("Is user client's friend: {}".format(user.is_friend))
@@ -40,7 +40,7 @@ print("Threads: {}".format(threads))
# Gets the last 10 messages sent to the thread
messages = client.fetchThreadMessages(thread_id='<thread id>', limit=10)
messages = client.fetchThreadMessages(thread_id="<thread id>", limit=10)
# Since the message come in reversed order, reverse them
messages.reverse()
@@ -50,13 +50,13 @@ for message in messages:
# If we have a thread id, we can use `fetchThreadInfo` to fetch a `Thread` object
thread = client.fetchThreadInfo('<thread id>')['<thread id>']
thread = client.fetchThreadInfo("<thread id>")["<thread id>"]
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]
thread = client.searchForThreads("<name of thread>")[0]
print("thread's name: {}".format(thread.name))
print("thread's type: {}".format(thread.type))

View File

@@ -5,57 +5,89 @@ from fbchat.models import *
client = Client("<email>", "<password>")
thread_id = '1234567890'
thread_id = "1234567890"
thread_type = ThreadType.GROUP
# Will send a message to the thread
client.send(Message(text='<message>'), thread_id=thread_id, thread_type=thread_type)
client.send(Message(text="<message>"), thread_id=thread_id, thread_type=thread_type)
# Will send the default `like` emoji
client.send(Message(emoji_size=EmojiSize.LARGE), thread_id=thread_id, thread_type=thread_type)
client.send(
Message(emoji_size=EmojiSize.LARGE), thread_id=thread_id, thread_type=thread_type
)
# Will send the emoji `👍`
client.send(Message(text='👍', emoji_size=EmojiSize.LARGE), thread_id=thread_id, thread_type=thread_type)
client.send(
Message(text="👍", emoji_size=EmojiSize.LARGE),
thread_id=thread_id,
thread_type=thread_type,
)
# Will send the sticker with ID `767334476626295`
client.send(Message(sticker=Sticker('767334476626295')), thread_id=thread_id, thread_type=thread_type)
client.send(
Message(sticker=Sticker("767334476626295")),
thread_id=thread_id,
thread_type=thread_type,
)
# Will send a message with a mention
client.send(Message(text='This is a @mention', mentions=[Mention(thread_id, offset=10, length=8)]), thread_id=thread_id, thread_type=thread_type)
client.send(
Message(
text="This is a @mention", mentions=[Mention(thread_id, offset=10, length=8)]
),
thread_id=thread_id,
thread_type=thread_type,
)
# Will send the image located at `<image path>`
client.sendLocalImage('<image path>', message=Message(text='This is a local image'), thread_id=thread_id, thread_type=thread_type)
client.sendLocalImage(
"<image path>",
message=Message(text="This is a local image"),
thread_id=thread_id,
thread_type=thread_type,
)
# Will download the image at the url `<image url>`, and then send it
client.sendRemoteImage('<image url>', message=Message(text='This is a remote image'), thread_id=thread_id, thread_type=thread_type)
client.sendRemoteImage(
"<image url>",
message=Message(text="This is a remote image"),
thread_id=thread_id,
thread_type=thread_type,
)
# Only do these actions if the thread is a group
if thread_type == ThreadType.GROUP:
# Will remove the user with ID `<user id>` from the thread
client.removeUserFromGroup('<user id>', thread_id=thread_id)
client.removeUserFromGroup("<user id>", thread_id=thread_id)
# Will add the user with ID `<user id>` to the thread
client.addUsersToGroup('<user id>', thread_id=thread_id)
client.addUsersToGroup("<user id>", thread_id=thread_id)
# 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)
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>`
client.changeNickname('<new nickname>', '<user id>', thread_id=thread_id, thread_type=thread_type)
client.changeNickname(
"<new nickname>", "<user id>", thread_id=thread_id, thread_type=thread_type
)
# Will change the title of the thread to `<title>`
client.changeThreadTitle('<title>', thread_id=thread_id, thread_type=thread_type)
client.changeThreadTitle("<title>", thread_id=thread_id, thread_type=thread_type)
# Will set the typing status of the thread to `TYPING`
client.setTypingStatus(TypingStatus.TYPING, thread_id=thread_id, thread_type=thread_type)
client.setTypingStatus(
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 `👍`
client.changeThreadEmoji('👍', thread_id=thread_id)
client.changeThreadEmoji("👍", thread_id=thread_id)
# Will react to a message with a 😍 emoji
client.reactToMessage('<message id>', MessageReaction.LOVE)
client.reactToMessage("<message id>", MessageReaction.LOVE)

View File

@@ -4,28 +4,33 @@ from fbchat import log, Client
from fbchat.models import *
# Change this to your group id
old_thread_id = '1234567890'
old_thread_id = "1234567890"
# Change these to match your liking
old_color = ThreadColor.MESSENGER_BLUE
old_emoji = '👍'
old_title = 'Old group chat name'
old_emoji = "👍"
old_title = "Old group chat name"
old_nicknames = {
'12345678901': "User nr. 1's nickname",
'12345678902': "User nr. 2's nickname",
'12345678903': "User nr. 3's nickname",
'12345678904': "User nr. 4's nickname"
"12345678901": "User nr. 1's nickname",
"12345678902": "User nr. 2's nickname",
"12345678903": "User nr. 3's nickname",
"12345678904": "User nr. 4's nickname",
}
class KeepBot(Client):
def onColorChange(self, author_id, new_color, thread_id, thread_type, **kwargs):
if old_thread_id == thread_id and old_color != new_color:
log.info("{} changed the thread color. It will be changed back".format(author_id))
log.info(
"{} changed the thread color. It will be changed back".format(author_id)
)
self.changeThreadColor(old_color, thread_id=thread_id)
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))
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):
@@ -36,19 +41,43 @@ class KeepBot(Client):
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:
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)
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,
)
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>")
client.listen()

View File

@@ -3,15 +3,23 @@
from fbchat import log, Client
from fbchat.models import *
class RemoveBot(Client):
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
if message_object.text == 'Remove me!' and thread_type == ThreadType.GROUP:
log.info('{} will be removed from {}'.format(author_id, thread_id))
if message_object.text == "Remove me!" and thread_type == ThreadType.GROUP:
log.info("{} will be removed from {}".format(author_id, thread_id))
self.removeUserFromGroup(author_id, thread_id=thread_id)
else:
# 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)
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>")
client.listen()

View File

@@ -4,21 +4,22 @@
:copyright: (c) 2015 - 2019 by Taehoon Kim
:license: BSD 3-Clause, see LICENSE for more details.
"""
from __future__ import unicode_literals
from .client import *
# These imports are far too general, but they're needed for backwards compatbility.
from .models import *
__title__ = 'fbchat'
__version__ = '1.6.1'
__description__ = 'Facebook Chat (Messenger) for Python'
from ._client import Client
from ._util import log # TODO: Remove this (from examples too)
__copyright__ = 'Copyright 2015 - 2019 by Taehoon Kim'
__license__ = 'BSD 3-Clause'
__title__ = "fbchat"
__version__ = "1.7.0"
__description__ = "Facebook Chat (Messenger) for Python"
__author__ = 'Taehoon Kim; Moreels Pieter-Jan; Mads Marquart'
__email__ = 'carpedm20@gmail.com'
__copyright__ = "Copyright 2015 - 2019 by Taehoon Kim"
__license__ = "BSD 3-Clause"
__all__ = [
'Client',
]
__author__ = "Taehoon Kim; Moreels Pieter-Jan; Mads Marquart"
__email__ = "carpedm20@gmail.com"
__all__ = ["Client"]

86
fbchat/_attachment.py Normal file
View File

@@ -0,0 +1,86 @@
# -*- 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

File diff suppressed because it is too large Load Diff

26
fbchat/_core.py Normal file
View File

@@ -0,0 +1,26 @@
# -*- 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)

32
fbchat/_exception.py Normal file
View File

@@ -0,0 +1,32 @@
# -*- coding: UTF-8 -*-
from __future__ import unicode_literals
class FBchatException(Exception):
"""Custom exception thrown by fbchat. All exceptions in the fbchat module inherits this"""
class FBchatFacebookError(FBchatException):
#: The error code that Facebook returned
fb_error_code = None
#: 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__(
self,
message,
fb_error_code=None,
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 FBchatUserError(FBchatException):
"""Thrown by fbchat when wrong values are entered"""

277
fbchat/_file.py Normal file
View File

@@ -0,0 +1,277 @@
# -*- 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

232
fbchat/_graphql.py Normal file
View File

@@ -0,0 +1,232 @@
# -*- coding: UTF-8 -*-
from __future__ import unicode_literals
import json
import re
from . import _util
from ._exception import FBchatException, FBchatUserError
# Shameless copy from https://stackoverflow.com/a/8730674
FLAGS = re.VERBOSE | re.MULTILINE | re.DOTALL
WHITESPACE = re.compile(r"[ \t\n\r]*", FLAGS)
class ConcatJSONDecoder(json.JSONDecoder):
def decode(self, s, _w=WHITESPACE.match):
s_len = len(s)
objs = []
end = 0
while end != s_len:
obj, end = self.raw_decode(s, idx=_w(s, end).end())
end = _w(s, end).end()
objs.append(obj)
return objs
# End shameless copy
def graphql_queries_to_json(*queries):
"""
Queries should be a list of GraphQL objects
"""
rtn = {}
for i, query in enumerate(queries):
rtn["q{}".format(i)] = query.value
return json.dumps(rtn)
def graphql_response_to_json(content):
content = _util.strip_to_json(content) # Usually only needed in some error cases
try:
j = json.loads(content, cls=ConcatJSONDecoder)
except Exception:
raise FBchatException("Error while parsing JSON: {}".format(repr(content)))
rtn = [None] * (len(j))
for x in j:
if "error_results" in x:
del rtn[-1]
continue
_util.check_json(x)
[(key, value)] = x.items()
_util.check_json(value)
if "response" in value:
rtn[int(key[1:])] = value["response"]
else:
rtn[int(key[1:])] = value["data"]
_util.log.debug(rtn)
return rtn
class GraphQL(object):
def __init__(self, query=None, doc_id=None, params=None):
if params is None:
params = {}
if query is not None:
self.value = {"priority": 0, "q": query, "query_params": params}
elif doc_id is not None:
self.value = {"doc_id": doc_id, "query_params": params}
else:
raise FBchatUserError("A query or doc_id must be specified")
FRAGMENT_USER = """
QueryFragment User: User {
id,
name,
first_name,
last_name,
profile_picture.width(<pic_size>).height(<pic_size>) {
uri
},
is_viewer_friend,
url,
gender,
viewer_affinity
}
"""
FRAGMENT_GROUP = """
QueryFragment Group: MessageThread {
name,
thread_key {
thread_fbid
},
image {
uri
},
is_group_thread,
all_participants {
nodes {
messaging_actor {
id
}
}
},
customization_info {
participant_customizations {
participant_id,
nickname
},
outgoing_bubble_color,
emoji
},
thread_admins {
id
},
group_approval_queue {
nodes {
requester {
id
}
}
},
approval_mode,
joinable_mode {
mode,
link
},
event_reminders {
nodes {
id,
lightweight_event_creator {
id
},
time,
location_name,
event_title,
event_reminder_members {
edges {
node {
id
},
guest_list_state
}
}
}
}
}
"""
FRAGMENT_PAGE = """
QueryFragment Page: Page {
id,
name,
profile_picture.width(32).height(32) {
uri
},
url,
category_type,
city {
name
}
}
"""
SEARCH_USER = (
"""
Query SearchUser(<search> = '', <limit> = 10) {
entities_named(<search>) {
search_results.of_type(user).first(<limit>) as users {
nodes {
@User
}
}
}
}
"""
+ FRAGMENT_USER
)
SEARCH_GROUP = (
"""
Query SearchGroup(<search> = '', <limit> = 10, <pic_size> = 32) {
viewer() {
message_threads.with_thread_name(<search>).last(<limit>) as groups {
nodes {
@Group
}
}
}
}
"""
+ FRAGMENT_GROUP
)
SEARCH_PAGE = (
"""
Query SearchPage(<search> = '', <limit> = 10) {
entities_named(<search>) {
search_results.of_type(page).first(<limit>) as pages {
nodes {
@Page
}
}
}
}
"""
+ FRAGMENT_PAGE
)
SEARCH_THREAD = (
"""
Query SearchThread(<search> = '', <limit> = 10) {
entities_named(<search>) {
search_results.first(<limit>) as threads {
nodes {
__typename,
@User,
@Group,
@Page
}
}
}
}
"""
+ FRAGMENT_USER
+ FRAGMENT_GROUP
+ FRAGMENT_PAGE
)

118
fbchat/_group.py Normal file
View File

@@ -0,0 +1,118 @@
# -*- 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

112
fbchat/_location.py Normal file
View File

@@ -0,0 +1,112 @@
# -*- 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

347
fbchat/_message.py Normal file
View File

@@ -0,0 +1,347 @@
# -*- 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

60
fbchat/_page.py Normal file
View File

@@ -0,0 +1,60 @@
# -*- 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 Normal file
View File

@@ -0,0 +1,103 @@
# -*- 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

67
fbchat/_poll.py Normal file
View File

@@ -0,0 +1,67 @@
# -*- 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")
),
)

99
fbchat/_quick_reply.py Normal file
View File

@@ -0,0 +1,99 @@
# -*- coding: UTF-8 -*-
from __future__ import unicode_literals
import attr
from ._attachment import Attachment
@attr.s(cmp=False)
class QuickReply(object):
"""Represents a quick reply"""
#: Payload of the quick reply
payload = attr.ib(None)
#: External payload for responses
external_payload = attr.ib(None, init=False)
#: Additional data
data = attr.ib(None)
#: Whether it's a response for a quick reply
is_response = attr.ib(False)
@attr.s(cmp=False, init=False)
class QuickReplyText(QuickReply):
"""Represents a text quick reply"""
#: Title of the quick reply
title = attr.ib(None)
#: URL of the quick reply image (optional)
image_url = attr.ib(None)
#: Type of the quick reply
_type = "text"
def __init__(self, title=None, image_url=None, **kwargs):
super(QuickReplyText, self).__init__(**kwargs)
self.title = title
self.image_url = image_url
@attr.s(cmp=False, init=False)
class QuickReplyLocation(QuickReply):
"""Represents a location quick reply (Doesn't work on mobile)"""
#: Type of the quick reply
_type = "location"
def __init__(self, **kwargs):
super(QuickReplyLocation, self).__init__(**kwargs)
self.is_response = False
@attr.s(cmp=False, init=False)
class QuickReplyPhoneNumber(QuickReply):
"""Represents a phone number quick reply (Doesn't work on mobile)"""
#: URL of the quick reply image (optional)
image_url = attr.ib(None)
#: Type of the quick reply
_type = "user_phone_number"
def __init__(self, image_url=None, **kwargs):
super(QuickReplyPhoneNumber, self).__init__(**kwargs)
self.image_url = image_url
@attr.s(cmp=False, init=False)
class QuickReplyEmail(QuickReply):
"""Represents an email quick reply (Doesn't work on mobile)"""
#: URL of the quick reply image (optional)
image_url = attr.ib(None)
#: Type of the quick reply
_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):
data = dict()
_type = q.get("content_type").lower()
if q.get("payload"):
data["payload"] = q["payload"]
if q.get("data"):
data["data"] = q["data"]
if q.get("image_url") and _type is not QuickReplyLocation._type:
data["image_url"] = q["image_url"]
data["is_response"] = is_response
if _type == QuickReplyText._type:
if q.get("title") is not None:
data["title"] = q["title"]
rtn = QuickReplyText(**data)
elif _type == QuickReplyLocation._type:
rtn = QuickReplyLocation(**data)
elif _type == QuickReplyPhoneNumber._type:
rtn = QuickReplyPhoneNumber(**data)
elif _type == QuickReplyEmail._type:
rtn = QuickReplyEmail(**data)
return rtn

60
fbchat/_sticker.py Normal file
View File

@@ -0,0 +1,60 @@
# -*- 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

129
fbchat/_thread.py Normal file
View File

@@ -0,0 +1,129 @@
# -*- 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

208
fbchat/_user.py Normal file
View File

@@ -0,0 +1,208 @@
# -*- 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,
)

View File

@@ -11,15 +11,16 @@ from os.path import basename
import warnings
import logging
import requests
import aenum
from .models import *
from ._exception import FBchatException, FBchatFacebookError
try:
from urllib.parse import urlencode, parse_qs, urlparse
basestring = (str, bytes)
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
@@ -42,51 +43,13 @@ USER_AGENTS = [
"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"
"Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/536.6 (KHTML, like Gecko) Chrome/20.0.1092.0 Safari/536.6",
]
LIKES = {
'large': EmojiSize.LARGE,
'medium': EmojiSize.MEDIUM,
'small': EmojiSize.SMALL,
'l': EmojiSize.LARGE,
'm': EmojiSize.MEDIUM,
's': EmojiSize.SMALL
}
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 ReqUrl(object):
"""A class containing all urls used by `fbchat`"""
SEARCH = "https://www.facebook.com/ajax/typeahead/search.php"
LOGIN = "https://m.facebook.com/login.php?login_attempt=1"
SEND = "https://www.facebook.com/messaging/send/"
@@ -94,8 +57,12 @@ class ReqUrl(object):
UNSEEN_THREADS = "https://www.facebook.com/mercury/unseen_thread_ids/"
THREADS = "https://www.facebook.com/ajax/mercury/threadlist_info.php"
MOVE_THREAD = "https://www.facebook.com/ajax/mercury/move_thread.php"
ARCHIVED_STATUS = "https://www.facebook.com/ajax/mercury/change_archived_status.php?dpr=1"
PINNED_STATUS = "https://www.facebook.com/ajax/mercury/change_pinned_status.php?dpr=1"
ARCHIVED_STATUS = (
"https://www.facebook.com/ajax/mercury/change_archived_status.php?dpr=1"
)
PINNED_STATUS = (
"https://www.facebook.com/ajax/mercury/change_pinned_status.php?dpr=1"
)
MESSAGES = "https://www.facebook.com/ajax/mercury/thread_info.php"
READ_STATUS = "https://www.facebook.com/ajax/mercury/change_read_status.php"
DELIVERED = "https://www.facebook.com/ajax/mercury/delivery_receipts.php"
@@ -135,14 +102,19 @@ class ReqUrl(object):
DELETE_THREAD = "https://www.facebook.com/ajax/mercury/delete_thread.php?dpr=1"
DELETE_MESSAGES = "https://www.facebook.com/ajax/mercury/delete_messages.php?dpr=1"
MUTE_THREAD = "https://www.facebook.com/ajax/mercury/change_mute_thread.php?dpr=1"
MUTE_REACTIONS = "https://www.facebook.com/ajax/mercury/change_reactions_mute_thread/?dpr=1"
MUTE_MENTIONS = "https://www.facebook.com/ajax/mercury/change_mentions_mute_thread/?dpr=1"
MUTE_REACTIONS = (
"https://www.facebook.com/ajax/mercury/change_reactions_mute_thread/?dpr=1"
)
MUTE_MENTIONS = (
"https://www.facebook.com/ajax/mercury/change_mentions_mute_thread/?dpr=1"
)
CREATE_POLL = "https://www.facebook.com/messaging/group_polling/create_poll/?dpr=1"
UPDATE_VOTE = "https://www.facebook.com/messaging/group_polling/update_vote/?dpr=1"
GET_POLL_OPTIONS = "https://www.facebook.com/ajax/mercury/get_poll_options"
SEARCH_MESSAGES = "https://www.facebook.com/ajax/mercury/search_snippets.php?dpr=1"
MARK_SPAM = "https://www.facebook.com/ajax/mercury/mark_spam.php?dpr=1"
UNSEND = "https://www.facebook.com/messaging/unsend_message/?dpr=1"
FORWARD_ATTACHMENT = "https://www.facebook.com/mercury/attachments/forward/"
pull_channel = 0
@@ -152,110 +124,143 @@ class ReqUrl(object):
else:
self.pull_channel = channel
self.STICKY = "https://{}-edge-chat.facebook.com/pull".format(self.pull_channel)
self.PING = "https://{}-edge-chat.facebook.com/active_ping".format(self.pull_channel)
self.PING = "https://{}-edge-chat.facebook.com/active_ping".format(
self.pull_channel
)
facebookEncoding = 'UTF-8'
facebookEncoding = "UTF-8"
def now():
return int(time() * 1000)
def strip_to_json(text):
try:
return text[text.index('{'):]
return text[text.index("{") :]
except ValueError:
raise FBchatException('No JSON object found: {!r}'.format(text))
raise FBchatException("No JSON object found: {!r}".format(text))
def get_decoded_r(r):
return get_decoded(r._content)
def get_decoded(content):
return content.decode(facebookEncoding)
def parse_json(content):
return json.loads(content)
def get_json(r):
return json.loads(strip_to_json(get_decoded_r(r)))
def digitToChar(digit):
if digit < 10:
return str(digit)
return chr(ord('a') + digit - 10)
return chr(ord("a") + digit - 10)
def str_base(number, base):
if number < 0:
return '-' + str_base(-number, base)
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:]
msgs = format(ret, 'b') + string
string = ("0000000000000000000000" + format(value, "b"))[-22:]
msgs = format(ret, "b") + string
return str(int(msgs, 2))
def check_json(j):
if j.get('error') is None:
return
if 'errorDescription' in j:
if hasattr(j.get("payload"), "get") and j["payload"].get("error"):
raise FBchatFacebookError(
"Error when sending request: {}".format(j["payload"]["error"]),
fb_error_code=None,
fb_error_message=j["payload"]["error"],
)
elif j.get("error"):
if "errorDescription" in j:
# 'errorDescription' is in the users own language!
raise FBchatFacebookError('Error #{} when sending request: {}'.format(j['error'], j['errorDescription']), fb_error_code=j['error'], fb_error_message=j['errorDescription'])
elif 'debug_info' in j['error'] and 'code' in j['error']:
raise FBchatFacebookError('Error #{} when sending request: {}'.format(j['error']['code'], repr(j['error']['debug_info'])), fb_error_code=j['error']['code'], fb_error_message=j['error']['debug_info'])
raise FBchatFacebookError(
"Error #{} when sending request: {}".format(
j["error"], j["errorDescription"]
),
fb_error_code=j["error"],
fb_error_message=j["errorDescription"],
)
elif "debug_info" in j["error"] and "code" in j["error"]:
raise FBchatFacebookError(
"Error #{} when sending request: {}".format(
j["error"]["code"], repr(j["error"]["debug_info"])
),
fb_error_code=j["error"]["code"],
fb_error_message=j["error"]["debug_info"],
)
else:
raise FBchatFacebookError('Error {} when sending request'.format(j['error']), fb_error_code=j['error'])
raise FBchatFacebookError(
"Error {} when sending request".format(j["error"]),
fb_error_code=j["error"],
)
def check_request(r, as_json=True):
if not r.ok:
raise FBchatFacebookError('Error when sending request: Got {} response'.format(r.status_code), request_status_code=r.status_code)
raise FBchatFacebookError(
"Error when sending request: Got {} response".format(r.status_code),
request_status_code=r.status_code,
)
content = get_decoded_r(r)
if content is None or len(content) == 0:
raise FBchatFacebookError('Error when sending request: Got empty response')
raise FBchatFacebookError("Error when sending request: Got empty response")
if as_json:
content = strip_to_json(content)
try:
j = json.loads(content)
except ValueError:
raise FBchatFacebookError('Error while parsing JSON: {!r}'.format(content))
raise FBchatFacebookError("Error while parsing JSON: {!r}".format(content))
check_json(j)
log.debug(j)
return j
else:
return content
def get_jsmods_require(j, index):
if j.get('jsmods') and j['jsmods'].get('require'):
if j.get("jsmods") and j["jsmods"].get("require"):
try:
return j['jsmods']['require'][0][index][0]
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))
log.warning(
"Error when getting jsmods_require: {}. Facebook might have changed protocol".format(
j
)
)
return None
def get_emojisize_from_tags(tags):
if tags is None:
return None
tmp = [tag for tag in tags if tag.startswith('hot_emoji_size:')]
if len(tmp) > 0:
try:
return LIKES[tmp[0].split(':')[1]]
except (KeyError, IndexError):
log.exception('Could not determine emoji size from {} - {}'.format(tags, tmp))
return None
def require_list(list_):
if isinstance(list_, list):
@@ -263,6 +268,7 @@ def require_list(list_):
else:
return set([list_])
def mimetype_to_key(mimetype):
if not mimetype:
return "file_id"
@@ -280,11 +286,13 @@ def get_files_from_urls(file_urls):
r = requests.get(file_url)
# We could possibly use r.headers.get('Content-Disposition'), see
# https://stackoverflow.com/a/37060758
files.append((
basename(file_url),
files.append(
(
basename(file_url).split("?")[0].split("#")[0],
r.content,
r.headers.get('Content-Type') or guess_type(file_url)[0],
))
r.headers.get("Content-Type") or guess_type(file_url)[0],
)
)
return files
@@ -292,26 +300,18 @@ def get_files_from_urls(file_urls):
def get_files_from_paths(filenames):
files = []
for filename in filenames:
files.append((
basename(filename),
open(filename, 'rb'),
guess_type(filename)[0],
))
files.append(
(basename(filename), open(filename, "rb"), guess_type(filename)[0])
)
yield files
for fn, fp, ft in files:
fp.close()
def enum_extend_if_invalid(enumeration, value):
try:
return enumeration(value)
except ValueError:
log.warning("Failed parsing {.__name__}({!r}). Extending enum.".format(enumeration, value))
aenum.extend_enum(enumeration, "UNKNOWN_{}".format(value).upper(), value)
return enumeration(value)
def get_url_parameters(url, *args):
params = parse_qs(urlparse(url).query)
return [params[arg][0] for arg in args if params.get(arg)]
def get_url_parameter(url, param):
return get_url_parameters(url, param)[0]

View File

@@ -1,634 +0,0 @@
# -*- coding: UTF-8 -*-
from __future__ import unicode_literals
import json
import re
from .models import *
from .utils import *
# Shameless copy from https://stackoverflow.com/a/8730674
FLAGS = re.VERBOSE | re.MULTILINE | re.DOTALL
WHITESPACE = re.compile(r'[ \t\n\r]*', FLAGS)
class ConcatJSONDecoder(json.JSONDecoder):
def decode(self, s, _w=WHITESPACE.match):
s_len = len(s)
objs = []
end = 0
while end != s_len:
obj, end = self.raw_decode(s, idx=_w(s, end).end())
end = _w(s, end).end()
objs.append(obj)
return objs
# End shameless copy
def graphql_color_to_enum(color):
if color is None:
return None
if not color:
return ThreadColor.MESSENGER_BLUE
color = color[2:] # Strip the alpha value
color_value = '#{}'.format(color.lower())
return enum_extend_if_invalid(ThreadColor, color_value)
def get_customization_info(thread):
if thread is None or thread.get('customization_info') is None:
return {}
info = thread['customization_info']
rtn = {
'emoji': info.get('emoji'),
'color': graphql_color_to_enum(info.get('outgoing_bubble_color'))
}
if thread.get('thread_type') == 'GROUP' or thread.get('is_group_thread') or thread.get('thread_key', {}).get('thread_fbid'):
rtn['nicknames'] = {}
for k in info.get('participant_customizations', []):
rtn['nicknames'][k['participant_id']] = k.get('nickname')
elif info.get('participant_customizations'):
uid = thread.get('thread_key', {}).get('other_user_id') or thread.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
def graphql_to_sticker(s):
if not s:
return None
sticker = Sticker(
uid=s['id']
)
if s.get('pack'):
sticker.pack = s['pack'].get('id')
if s.get('sprite_image'):
sticker.is_animated = True
sticker.medium_sprite_image = s['sprite_image'].get('uri')
sticker.large_sprite_image = s['sprite_image_2x'].get('uri')
sticker.frames_per_row = s.get('frames_per_row')
sticker.frames_per_col = s.get('frames_per_column')
sticker.frame_rate = s.get('frame_rate')
sticker.url = s.get('url')
sticker.width = s.get('width')
sticker.height = s.get('height')
if s.get('label'):
sticker.label = s['label']
return sticker
def graphql_to_attachment(a):
_type = a['__typename']
if _type in ['MessageImage', 'MessageAnimatedImage']:
return ImageAttachment(
original_extension=a.get('original_extension') or (a['filename'].split('-')[0] if a.get('filename') else None),
width=a.get('original_dimensions', {}).get('width'),
height=a.get('original_dimensions', {}).get('height'),
is_animated=_type=='MessageAnimatedImage',
thumbnail_url=a.get('thumbnail', {}).get('uri'),
preview=a.get('preview') or a.get('preview_image'),
large_preview=a.get('large_preview'),
animated_preview=a.get('animated_image'),
uid=a.get('legacy_attachment_id')
)
elif _type == 'MessageVideo':
return VideoAttachment(
width=a.get('original_dimensions', {}).get('width'),
height=a.get('original_dimensions', {}).get('height'),
duration=a.get('playable_duration_in_ms'),
preview_url=a.get('playable_url'),
small_image=a.get('chat_image'),
medium_image=a.get('inbox_image'),
large_image=a.get('large_image'),
uid=a.get('legacy_attachment_id')
)
elif _type == 'MessageAudio':
return AudioAttachment(
filename=a.get('filename'),
url=a.get('playable_url'),
duration=a.get('playable_duration_in_ms'),
audio_type=a.get('audio_type')
)
elif _type == 'MessageFile':
return FileAttachment(
url=a.get('url'),
name=a.get('filename'),
is_malicious=a.get('is_malicious'),
uid=a.get('message_file_fbid')
)
else:
return Attachment(
uid=a.get('legacy_attachment_id')
)
def graphql_to_extensible_attachment(a):
story = a.get('story_attachment')
if story:
target = story.get('target')
if target:
_type = target['__typename']
if _type == 'MessageLocation':
latitude, longitude = get_url_parameter(get_url_parameter(story['url'], 'u'), 'where1').split(", ")
rtn = LocationAttachment(
uid=int(story['deduplication_key']),
latitude=float(latitude),
longitude=float(longitude),
)
if story['media']:
rtn.image_url = story['media']['image']['uri']
rtn.image_width = story['media']['image']['width']
rtn.image_height = story['media']['image']['height']
rtn.url = story['url']
return rtn
elif _type == 'MessageLiveLocation':
rtn = LiveLocationAttachment(
uid=int(story['target']['live_location_id']),
latitude=story['target']['coordinate']['latitude'] if story['target'].get('coordinate') else None,
longitude=story['target']['coordinate']['longitude'] if story['target'].get('coordinate') else None,
name=story['title_with_entities']['text'],
expiration_time=story['target']['expiration_time'] if story['target'].get('expiration_time') else None,
is_expired=story['target']['is_expired'],
)
if story['media']:
rtn.image_url = story['media']['image']['uri']
rtn.image_width = story['media']['image']['width']
rtn.image_height = story['media']['image']['height']
rtn.url = story['url']
return rtn
elif _type in ['ExternalUrl', 'Story']:
return ShareAttachment(
uid=a.get('legacy_attachment_id'),
author=story['target']['actors'][0]['id'] if story['target'].get('actors') else None,
url=story['url'],
original_url=get_url_parameter(story['url'], 'u') if "/l.php?u=" in story['url'] else story['url'],
title=story['title_with_entities'].get('text'),
description=story['description'].get('text') if story.get('description') else None,
source=story['source']['text'],
image_url=story['media']['image']['uri'] if story.get('media') else None,
original_image_url=(get_url_parameter(story['media']['image']['uri'], 'url') if "/safe_image.php" in story['media']['image']['uri'] else story['media']['image']['uri']) if story.get('media') else None,
image_width=story['media']['image']['width'] if story.get('media') else None,
image_height=story['media']['image']['height'] if story.get('media') else None,
attachments=[graphql_to_subattachment(attachment) for attachment in story.get('subattachments')],
)
else:
return UnsentMessage(
uid=a.get('legacy_attachment_id'),
)
def graphql_to_subattachment(a):
_type = a['target']['__typename']
if _type == 'Video':
return VideoAttachment(
duration=a['media'].get('playable_duration_in_ms'),
preview_url=a['media'].get('playable_url'),
medium_image=a['media'].get('image'),
uid=a['target'].get('video_id'),
)
def graphql_to_live_location(a):
return LiveLocationAttachment(
uid=a['id'],
latitude=a['coordinate']['latitude'] / (10 ** 8) if not a.get('stopReason') else None,
longitude=a['coordinate']['longitude'] / (10 ** 8) if not a.get('stopReason') else None,
name=a.get('locationTitle'),
expiration_time=a['expirationTime'],
is_expired=bool(a.get('stopReason')),
)
def graphql_to_poll(a):
rtn = Poll(
title=a.get('title') if a.get('title') else a.get('text'),
options=[graphql_to_poll_option(m) for m in a.get('options')]
)
rtn.uid = int(a["id"])
rtn.options_count = a.get("total_count")
return rtn
def graphql_to_poll_option(a):
if a.get('viewer_has_voted') is None:
vote = None
elif isinstance(a['viewer_has_voted'], bool):
vote = a['viewer_has_voted']
else:
vote = a['viewer_has_voted'] == 'true'
rtn = PollOption(
text=a.get('text'),
vote=vote
)
rtn.uid = int(a["id"])
rtn.voters = [m.get('node').get('id') for m in a.get('voters').get('edges')] if isinstance(a.get('voters'), dict) else a.get('voters')
rtn.votes_count = a.get('voters').get('count') if isinstance(a.get('voters'), dict) else a.get('total_count')
return rtn
def graphql_to_plan(a):
if a.get('event_members'):
rtn = Plan(
time=a.get('event_time'),
title=a.get('title'),
location=a.get('location_name')
)
if a.get('location_id') != 0:
rtn.location_id = str(a.get('location_id'))
rtn.uid = a.get('oid')
rtn.author_id = a.get('creator_id')
guests = a.get("event_members")
rtn.going = [uid for uid in guests if guests[uid] == "GOING"]
rtn.declined = [uid for uid in guests if guests[uid] == "DECLINED"]
rtn.invited = [uid for uid in guests if guests[uid] == "INVITED"]
return rtn
elif a.get('id') is None:
rtn = Plan(
time=a.get('event_time'),
title=a.get('event_title'),
location=a.get('event_location_name'),
location_id=a.get('event_location_id')
)
rtn.uid = a.get('event_id')
rtn.author_id = a.get('event_creator_id')
guests = json.loads(a.get('guest_state_list'))
else:
rtn = Plan(
time=a.get('time'),
title=a.get('event_title'),
location=a.get('location_name')
)
rtn.uid = a.get('id')
rtn.author_id = a.get('lightweight_event_creator').get('id')
guests = a.get('event_reminder_members').get('edges')
rtn.going = [m.get('node').get('id') for m in guests if m.get('guest_list_state') == "GOING"]
rtn.declined = [m.get('node').get('id') for m in guests if m.get('guest_list_state') == "DECLINED"]
rtn.invited = [m.get('node').get('id') for m in guests if m.get('guest_list_state') == "INVITED"]
return rtn
def graphql_to_quick_reply(q, is_response=False):
data = dict()
_type = q.get('content_type').lower()
if q.get('payload'): data["payload"] = q["payload"]
if q.get('data'): data["data"] = q["data"]
if q.get('image_url') and _type is not QuickReplyLocation._type: data["image_url"] = q["image_url"]
data["is_response"] = is_response
if _type == QuickReplyText._type:
if q.get('title') is not None: data["title"] = q["title"]
rtn = QuickReplyText(**data)
elif _type == QuickReplyLocation._type:
rtn = QuickReplyLocation(**data)
elif _type == QuickReplyPhoneNumber._type:
rtn = QuickReplyPhoneNumber(**data)
elif _type == QuickReplyEmail._type:
rtn = QuickReplyEmail(**data)
return rtn
def graphql_to_message(message):
if message.get('message_sender') is None:
message['message_sender'] = {}
if message.get('message') is None:
message['message'] = {}
rtn = Message(
text=message.get('message').get('text'),
mentions=[Mention(m.get('entity', {}).get('id'), offset=m.get('offset'), length=m.get('length')) for m in message.get('message').get('ranges', [])],
emoji_size=get_emojisize_from_tags(message.get('tags_list')),
sticker=graphql_to_sticker(message.get('sticker'))
)
rtn.uid = str(message.get('message_id'))
rtn.author = str(message.get('message_sender').get('id'))
rtn.timestamp = message.get('timestamp_precise')
rtn.unsent = False
if message.get('unread') is not None:
rtn.is_read = not message['unread']
rtn.reactions = {
str(r['user']['id']): enum_extend_if_invalid(MessageReaction, r['reaction'])
for r in message.get('message_reactions')
}
if message.get('blob_attachments') is not None:
rtn.attachments = [graphql_to_attachment(attachment) for attachment in message['blob_attachments']]
if message.get('platform_xmd_encoded'):
quick_replies = json.loads(message['platform_xmd_encoded']).get('quick_replies')
if isinstance(quick_replies, list):
rtn.quick_replies = [graphql_to_quick_reply(q) for q in quick_replies]
elif isinstance(quick_replies, dict):
rtn.quick_replies = [graphql_to_quick_reply(quick_replies, is_response=True)]
if message.get('extensible_attachment') is not None:
attachment = graphql_to_extensible_attachment(message['extensible_attachment'])
if isinstance(attachment, UnsentMessage):
rtn.unsent = True
elif attachment:
rtn.attachments.append(attachment)
return rtn
def graphql_to_user(user):
if user.get('profile_picture') is None:
user['profile_picture'] = {}
c_info = get_customization_info(user)
plan = None
if user.get('event_reminders'):
plan = graphql_to_plan(user['event_reminders']['nodes'][0]) if user['event_reminders'].get('nodes') else None
return User(
user['id'],
url=user.get('url'),
first_name=user.get('first_name'),
last_name=user.get('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['profile_picture'].get('uri'),
name=user.get('name'),
message_count=user.get('messages_count'),
plan=plan,
)
def graphql_to_thread(thread):
if thread['thread_type'] == 'GROUP':
return graphql_to_group(thread)
elif thread['thread_type'] == 'ONE_TO_ONE':
if thread.get('big_image_src') is None:
thread['big_image_src'] = {}
c_info = get_customization_info(thread)
participants = [node['messaging_actor'] for node in thread['all_participants']['nodes']]
user = next(p for p in participants if p['id'] == thread['thread_key']['other_user_id'])
last_message_timestamp = None
if 'last_message' in thread:
last_message_timestamp = thread['last_message']['nodes'][0]['timestamp_precise']
first_name = user.get('short_name')
if first_name is None:
last_name = None
else:
last_name = user.get('name').split(first_name, 1).pop().strip()
plan = None
if thread.get('event_reminders'):
plan = graphql_to_plan(thread['event_reminders']['nodes'][0]) if thread['event_reminders'].get('nodes') else None
return User(
user['id'],
url=user.get('url'),
name=user.get('name'),
first_name=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=thread.get('messages_count'),
last_message_timestamp=last_message_timestamp,
plan=plan,
)
else:
raise FBchatException('Unknown thread type: {}, with data: {}'.format(thread.get('thread_type'), thread))
def graphql_to_group(group):
if group.get('image') is None:
group['image'] = {}
c_info = get_customization_info(group)
last_message_timestamp = None
if 'last_message' in group:
last_message_timestamp = group['last_message']['nodes'][0]['timestamp_precise']
plan = None
if group.get('event_reminders'):
plan = graphql_to_plan(group['event_reminders']['nodes'][0]) if group['event_reminders'].get('nodes') else None
return Group(
group['thread_key']['thread_fbid'],
participants=set([node['messaging_actor']['id'] for node in group['all_participants']['nodes']]),
nicknames=c_info.get('nicknames'),
color=c_info.get('color'),
emoji=c_info.get('emoji'),
admins = set([node.get('id') for node in group.get('thread_admins')]),
approval_mode = bool(group.get('approval_mode')) if group.get('approval_mode') is not None else None,
approval_requests = set(node["requester"]['id'] for node in group['group_approval_queue']['nodes']) if group.get('group_approval_queue') else None,
join_link = group['joinable_mode'].get('link'),
photo=group['image'].get('uri'),
name=group.get('name'),
message_count=group.get('messages_count'),
last_message_timestamp=last_message_timestamp,
plan=plan,
)
def graphql_to_page(page):
if page.get('profile_picture') is None:
page['profile_picture'] = {}
if page.get('city') is None:
page['city'] = {}
plan = None
if page.get('event_reminders'):
plan = graphql_to_plan(page['event_reminders']['nodes'][0]) if page['event_reminders'].get('nodes') else None
return Page(
page['id'],
url=page.get('url'),
city=page.get('city').get('name'),
category=page.get('category_type'),
photo=page['profile_picture'].get('uri'),
name=page.get('name'),
message_count=page.get('messages_count'),
plan=plan,
)
def graphql_queries_to_json(*queries):
"""
Queries should be a list of GraphQL objects
"""
rtn = {}
for i, query in enumerate(queries):
rtn['q{}'.format(i)] = query.value
return json.dumps(rtn)
def graphql_response_to_json(content):
content = strip_to_json(content) # Usually only needed in some error cases
try:
j = json.loads(content, cls=ConcatJSONDecoder)
except Exception:
raise FBchatException('Error while parsing JSON: {}'.format(repr(content)))
rtn = [None]*(len(j))
for x in j:
if 'error_results' in x:
del rtn[-1]
continue
check_json(x)
[(key, value)] = x.items()
check_json(value)
if 'response' in value:
rtn[int(key[1:])] = value['response']
else:
rtn[int(key[1:])] = value['data']
log.debug(rtn)
return rtn
class GraphQL(object):
def __init__(self, query=None, doc_id=None, params=None):
if params is None:
params = {}
if query is not None:
self.value = {
'priority': 0,
'q': query,
'query_params': params
}
elif doc_id is not None:
self.value = {
'doc_id': doc_id,
'query_params': params
}
else:
raise FBchatUserError('A query or doc_id must be specified')
FRAGMENT_USER = """
QueryFragment User: User {
id,
name,
first_name,
last_name,
profile_picture.width(<pic_size>).height(<pic_size>) {
uri
},
is_viewer_friend,
url,
gender,
viewer_affinity
}
"""
FRAGMENT_GROUP = """
QueryFragment Group: MessageThread {
name,
thread_key {
thread_fbid
},
image {
uri
},
is_group_thread,
all_participants {
nodes {
messaging_actor {
id
}
}
},
customization_info {
participant_customizations {
participant_id,
nickname
},
outgoing_bubble_color,
emoji
},
thread_admins {
id
},
group_approval_queue {
nodes {
requester {
id
}
}
},
approval_mode,
joinable_mode {
mode,
link
},
event_reminders {
nodes {
id,
lightweight_event_creator {
id
},
time,
location_name,
event_title,
event_reminder_members {
edges {
node {
id
},
guest_list_state
}
}
}
}
}
"""
FRAGMENT_PAGE = """
QueryFragment Page: Page {
id,
name,
profile_picture.width(32).height(32) {
uri
},
url,
category_type,
city {
name
}
}
"""
SEARCH_USER = """
Query SearchUser(<search> = '', <limit> = 10) {
entities_named(<search>) {
search_results.of_type(user).first(<limit>) as users {
nodes {
@User
}
}
}
}
""" + FRAGMENT_USER
SEARCH_GROUP = """
Query SearchGroup(<search> = '', <limit> = 10, <pic_size> = 32) {
viewer() {
message_threads.with_thread_name(<search>).last(<limit>) as groups {
nodes {
@Group
}
}
}
}
""" + FRAGMENT_GROUP
SEARCH_PAGE = """
Query SearchPage(<search> = '', <limit> = 10) {
entities_named(<search>) {
search_results.of_type(page).first(<limit>) as pages {
nodes {
@Page
}
}
}
}
""" + FRAGMENT_PAGE
SEARCH_THREAD = """
Query SearchThread(<search> = '', <limit> = 10) {
entities_named(<search>) {
search_results.first(<limit>) as threads {
nodes {
__typename,
@User,
@Group,
@Page
}
}
}
}
""" + FRAGMENT_USER + FRAGMENT_GROUP + FRAGMENT_PAGE

View File

@@ -1,800 +1,29 @@
# -*- 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
import aenum
from string import Formatter
class FBchatException(Exception):
"""Custom exception thrown by fbchat. All exceptions in the fbchat module inherits this"""
class FBchatFacebookError(FBchatException):
#: The error code that Facebook returned
fb_error_code = None
#: 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__(self, message, fb_error_code=None, 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 FBchatUserError(FBchatException):
"""Thrown by fbchat when wrong values are entered"""
class Thread(object):
#: The unique identifier of the thread. Can be used a `thread_id`. See :ref:`intro_threads` for more info
uid = None
#: Specifies the type of thread. Can be used a `thread_type`. See :ref:`intro_threads` for more info
type = None
#: A url to the thread's picture
photo = None
#: The name of the thread
name = None
#: Timestamp of last message
last_message_timestamp = None
#: Number of messages in the thread
message_count = None
#: Set :class:`Plan`
plan = None
def __init__(self, _type, uid, photo=None, name=None, last_message_timestamp=None, message_count=None, plan=None):
"""Represents a Facebook thread"""
self.uid = str(uid)
self.type = _type
self.photo = photo
self.name = name
self.last_message_timestamp = last_message_timestamp
self.message_count = message_count
self.plan = plan
def __repr__(self):
return self.__unicode__()
def __unicode__(self):
return '<{} {} ({})>'.format(self.type.name, self.name, self.uid)
class User(Thread):
#: The profile url
url = None
#: The users first name
first_name = None
#: The users last name
last_name = None
#: Whether the user and the client are friends
is_friend = None
#: The user's gender
gender = None
#: From 0 to 1. How close the client is to the user
affinity = None
#: The user's nickname
nickname = None
#: The clients nickname, as seen by the user
own_nickname = None
#: A :class:`ThreadColor`. The message color
color = None
#: The default emoji
emoji = 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):
"""Represents a Facebook user. Inherits `Thread`"""
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
class Group(Thread):
#: Unique list (set) of the group thread's participant user IDs
participants = None
#: A dict, containing user nicknames mapped to their IDs
nicknames = None
#: A :class:`ThreadColor`. The groups's message color
color = None
#: The groups's default emoji
emoji = None
# Set containing user IDs of thread admins
admins = None
# True if users need approval to join
approval_mode = None
# Set containing user IDs requesting to join
approval_requests = None
# Link for joining group
join_link = None
def __init__(self, uid, participants=None, nicknames=None, color=None, emoji=None, admins=None, approval_mode=None, approval_requests=None, join_link=None, privacy_mode=None, **kwargs):
"""Represents a Facebook group. Inherits `Thread`"""
super(Group, self).__init__(ThreadType.GROUP, uid, **kwargs)
if participants is None:
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
class Room(Group):
# True is room is not discoverable
privacy_mode = None
def __init__(self, uid, privacy_mode=None, **kwargs):
"""Deprecated. Use :class:`Group` instead"""
super(Room, self).__init__(uid, **kwargs)
self.type = ThreadType.ROOM
self.privacy_mode = privacy_mode
class Page(Thread):
#: The page's custom url
url = None
#: The name of the page's location city
city = None
#: Amount of likes the page has
likes = None
#: Some extra information about the page
sub_title = None
#: The page's category
category = None
def __init__(self, uid, url=None, city=None, likes=None, sub_title=None, category=None, **kwargs):
"""Represents a Facebook page. Inherits `Thread`"""
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
class Message(object):
#: The actual message
text = None
#: A list of :class:`Mention` objects
mentions = None
#: A :class:`EmojiSize`. Size of a sent emoji
emoji_size = None
#: The message ID
uid = None
#: ID of the sender
author = None
#: Timestamp of when the message was sent
timestamp = None
#: Whether the message is read
is_read = None
#: A list of pepole IDs who read the message, works only with :func:`fbchat.Client.fetchThreadMessages`
read_by = None
#: A dict with user's IDs as keys, and their :class:`MessageReaction` as values
reactions = None
#: The actual message
text = None
#: A :class:`Sticker`
sticker = None
#: A list of attachments
attachments = None
#: A list of :class:`QuickReply`
quick_replies = None
#: Whether the message is unsent (deleted for everyone)
unsent = None
def __init__(self, text=None, mentions=None, emoji_size=None, sticker=None, attachments=None, quick_replies=None):
"""Represents a Facebook message"""
self.text = text
if mentions is None:
mentions = []
self.mentions = mentions
self.emoji_size = emoji_size
self.sticker = sticker
if attachments is None:
attachments = []
self.attachments = attachments
if quick_replies is None:
quick_replies = []
self.quick_replies = quick_replies
self.reactions = {}
self.read_by = []
self.deleted = False
def __repr__(self):
return self.__unicode__()
def __unicode__(self):
return '<Message ({}): {}, mentions={} emoji_size={} attachments={}>'.format(self.uid, repr(self.text), self.mentions, self.emoji_size, self.attachments)
@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
class Attachment(object):
#: The attachment ID
uid = None
def __init__(self, uid=None):
"""Represents a Facebook attachment"""
self.uid = uid
class UnsentMessage(Attachment):
def __init__(self, *args, **kwargs):
"""Represents an unsent message attachment"""
super(UnsentMessage, self).__init__(*args, **kwargs)
class Sticker(Attachment):
#: The sticker-pack's ID
pack = None
#: Whether the sticker is animated
is_animated = False
# If the sticker is animated, the following should be present
#: URL to a medium spritemap
medium_sprite_image = None
#: URL to a large spritemap
large_sprite_image = None
#: The amount of frames present in the spritemap pr. row
frames_per_row = None
#: The amount of frames present in the spritemap pr. coloumn
frames_per_col = None
#: The frame rate the spritemap is intended to be played in
frame_rate = None
#: URL to the sticker's image
url = None
#: Width of the sticker
width = None
#: Height of the sticker
height = None
#: The sticker's label/name
label = None
def __init__(self, *args, **kwargs):
"""Represents a Facebook sticker that has been sent to a Facebook thread as an attachment"""
super(Sticker, self).__init__(*args, **kwargs)
class ShareAttachment(Attachment):
#: ID of the author of the shared post
author = None
#: Target URL
url = None
#: Original URL if Facebook redirects the URL
original_url = None
#: Title of the attachment
title = None
#: Description of the attachment
description = None
#: Name of the source
source = None
#: URL of the attachment image
image_url = None
#: URL of the original image if Facebook uses `safe_image`
original_image_url = None
#: Width of the image
image_width = None
#: Height of the image
image_height = None
#: List of additional attachments
attachments = None
def __init__(self, author=None, url=None, original_url=None, title=None, description=None, source=None, image_url=None, original_image_url=None, image_width=None, image_height=None, attachments=None, **kwargs):
"""Represents a shared item (eg. URL) that has been sent as a Facebook attachment"""
super(ShareAttachment, self).__init__(**kwargs)
self.author = author
self.url = url
self.original_url = original_url
self.title = title
self.description = description
self.source = source
self.image_url = image_url
self.original_image_url = original_image_url
self.image_width = image_width
self.image_height = image_height
if attachments is None:
attachments = []
self.attachments = attachments
class LocationAttachment(Attachment):
#: Latidute of the location
latitude = None
#: Longitude of the location
longitude = None
#: URL of image showing the map of the location
image_url = None
#: Width of the image
image_width = None
#: Height of the image
image_height = None
#: URL to Bing maps with the location
url = None
def __init__(self, latitude=None, longitude=None, **kwargs):
"""Represents a user location"""
super(LocationAttachment, self).__init__(**kwargs)
self.latitude = latitude
self.longitude = longitude
class LiveLocationAttachment(LocationAttachment):
#: Name of the location
name = None
#: Timestamp when live location expires
expiration_time = None
#: True if live location is expired
is_expired = None
def __init__(self, name=None, expiration_time=None, is_expired=None, **kwargs):
"""Represents a live user location"""
super(LiveLocationAttachment, self).__init__(**kwargs)
self.expiration_time = expiration_time
self.is_expired = is_expired
class FileAttachment(Attachment):
#: Url where you can download the file
url = None
#: Size of the file in bytes
size = None
#: Name of the file
name = None
#: Whether Facebook determines that this file may be harmful
is_malicious = None
def __init__(self, url=None, size=None, name=None, is_malicious=None, **kwargs):
"""Represents a file that has been sent as a Facebook attachment"""
super(FileAttachment, self).__init__(**kwargs)
self.url = url
self.size = size
self.name = name
self.is_malicious = is_malicious
class AudioAttachment(Attachment):
#: Name of the file
filename = None
#: Url of the audio file
url = None
#: Duration of the audioclip in milliseconds
duration = None
#: Audio type
audio_type = None
def __init__(self, filename=None, url=None, duration=None, audio_type=None, **kwargs):
"""Represents an audio file that has been sent as a Facebook attachment"""
super(AudioAttachment, self).__init__(**kwargs)
self.filename = filename
self.url = url
self.duration = duration
self.audio_type = audio_type
class ImageAttachment(Attachment):
#: The extension of the original image (eg. 'png')
original_extension = None
#: Width of original image
width = None
#: Height of original image
height = None
#: Whether the image is animated
is_animated = None
#: URL to a thumbnail of the image
thumbnail_url = None
#: URL to a medium preview of the image
preview_url = None
#: Width of the medium preview image
preview_width = None
#: Height of the medium preview image
preview_height = None
#: URL to a large preview of the image
large_preview_url = None
#: Width of the large preview image
large_preview_width = None
#: Height of the large preview image
large_preview_height = None
#: URL to an animated preview of the image (eg. for gifs)
animated_preview_url = None
#: Width of the animated preview image
animated_preview_width = None
#: Height of the animated preview image
animated_preview_height = 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):
"""
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
"""
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')
class VideoAttachment(Attachment):
#: Size of the original video in bytes
size = None
#: Width of original video
width = None
#: Height of original video
height = None
#: Length of video in milliseconds
duration = None
#: URL to very compressed preview video
preview_url = None
#: URL to a small preview image of the video
small_image_url = None
#: Width of the small preview image
small_image_width = None
#: Height of the small preview image
small_image_height = None
#: URL to a medium preview image of the video
medium_image_url = None
#: Width of the medium preview image
medium_image_width = None
#: Height of the medium preview image
medium_image_height = None
#: URL to a large preview image of the video
large_image_url = None
#: Width of the large preview image
large_image_width = None
#: Height of the large preview image
large_image_height = 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):
"""Represents a video that has been sent as a Facebook attachment"""
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')
class Mention(object):
#: The thread ID the mention is pointing at
thread_id = None
#: The character where the mention starts
offset = None
#: The length of the mention
length = None
def __init__(self, thread_id, offset=0, length=10):
"""Represents a @mention"""
self.thread_id = thread_id
self.offset = offset
self.length = length
def __repr__(self):
return self.__unicode__()
def __unicode__(self):
return '<Mention {}: offset={} length={}>'.format(self.thread_id, self.offset, self.length)
class QuickReply(object):
#: Payload of the quick reply
payload = None
#: External payload for responses
external_payload = None
#: Additional data
data = None
#: Whether it's a response for a quick reply
is_response = None
def __init__(self, payload=None, data=None, is_response=False):
"""Represents a quick reply"""
self.payload = payload
self.data = data
self.is_response = is_response
def __repr__(self):
return self.__unicode__()
def __unicode__(self):
return '<{}: payload={!r}>'.format(self.__class__.__name__, self.payload)
class QuickReplyText(QuickReply):
#: Title of the quick reply
title = None
#: URL of the quick reply image (optional)
image_url = None
#: Type of the quick reply
_type = "text"
def __init__(self, title=None, image_url=None, **kwargs):
"""Represents a text quick reply"""
super(QuickReplyText, self).__init__(**kwargs)
self.title = title
self.image_url = image_url
class QuickReplyLocation(QuickReply):
#: Type of the quick reply
_type = "location"
def __init__(self, **kwargs):
"""Represents a location quick reply (Doesn't work on mobile)"""
super(QuickReplyLocation, self).__init__(**kwargs)
self.is_response = False
class QuickReplyPhoneNumber(QuickReply):
#: URL of the quick reply image (optional)
image_url = None
#: Type of the quick reply
_type = "user_phone_number"
def __init__(self, image_url=None, **kwargs):
"""Represents a phone number quick reply (Doesn't work on mobile)"""
super(QuickReplyPhoneNumber, self).__init__(**kwargs)
self.image_url = image_url
class QuickReplyEmail(QuickReply):
#: URL of the quick reply image (optional)
image_url = None
#: Type of the quick reply
_type = "user_email"
def __init__(self, image_url=None, **kwargs):
"""Represents an email quick reply (Doesn't work on mobile)"""
super(QuickReplyEmail, self).__init__(**kwargs)
self.image_url = image_url
class Poll(object):
#: ID of the poll
uid = None
#: Title of the poll
title = None
#: List of :class:`PollOption`, can be fetched with :func:`fbchat.Client.fetchPollOptions`
options = None
#: Options count
options_count = None
def __init__(self, title, options):
"""Represents a poll"""
self.title = title
self.options = options
def __repr__(self):
return self.__unicode__()
def __unicode__(self):
return '<Poll ({}): {} options={}>'.format(self.uid, repr(self.title), self.options)
class PollOption(object):
#: ID of the poll option
uid = None
#: Text of the poll option
text = None
#: Whether vote when creating or client voted
vote = None
#: ID of the users who voted for this poll option
voters = None
#: Votes count
votes_count = None
def __init__(self, text, vote=False):
"""Represents a poll option"""
self.text = text
self.vote = vote
def __repr__(self):
return self.__unicode__()
def __unicode__(self):
return '<PollOption ({}): {} voters={}>'.format(self.uid, repr(self.text), self.voters)
class Plan(object):
#: ID of the plan
uid = None
#: Plan time (unix time stamp), only precise down to the minute
time = None
#: Plan title
title = None
#: Plan location name
location = None
#: Plan location ID
location_id = None
#: ID of the plan creator
author_id = None
#: List of the people IDs who will take part in the plan
going = None
#: List of the people IDs who won't take part in the plan
declined = None
#: List of the people IDs who are invited to the plan
invited = None
def __init__(self, time, title, location=None, location_id=None):
"""Represents a plan"""
self.time = int(time)
self.title = title
self.location = location or ''
self.location_id = location_id or ''
self.author_id = None
self.going = []
self.declined = []
self.invited = []
def __repr__(self):
return self.__unicode__()
def __unicode__(self):
return '<Plan ({}): {} time={}, location={}, location_id={}>'.format(self.uid, repr(self.title), self.time, repr(self.location), repr(self.location_id))
class ActiveStatus(object):
#: Whether the user is active now
active = None
#: Timestamp when the user was last active
last_active = None
#: Whether the user is playing Messenger game now
in_game = None
def __init__(self, active=None, last_active=None, in_game=None):
self.active = active
self.last_active = last_active
self.in_game = in_game
def __repr__(self):
return self.__unicode__()
def __unicode__(self):
return '<ActiveStatus: active={} last_active={} in_game={}>'.format(self.active, self.last_active, self.in_game)
class Enum(aenum.Enum):
"""Used internally by fbchat to support enumerations"""
def __repr__(self):
# For documentation:
return '{}.{}'.format(type(self).__name__, self.name)
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 TypingStatus(Enum):
"""Used to specify whether the user is typing or has stopped typing"""
STOPPED = 0
TYPING = 1
class EmojiSize(Enum):
"""Used to specify the size of a sent emoji"""
LARGE = '369239383222810'
MEDIUM = '369239343222814'
SMALL = '369239263222822'
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'
class MessageReaction(Enum):
"""Used to specify a message reaction"""
LOVE = '😍'
SMILE = '😆'
WOW = '😮'
SAD = '😢'
ANGRY = '😠'
YES = '👍'
NO = '👎'
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

View File

@@ -1,3 +1,15 @@
[tool.black]
line-length = 88
exclude = '''
/(
\.git
| \.pytest_cache
| build
| dist
| venv
)/
'''
[build-system]
requires = ["flit"]
build-backend = "flit.buildapi"
@@ -11,6 +23,7 @@ maintainer-email = "madsmtm@gmail.com"
home-page = "https://github.com/carpedm20/fbchat/"
requires = [
"aenum",
"attrs~=18.2.0",
"requests",
"beautifulsoup4",
]

View File

@@ -17,17 +17,21 @@ def user(client2):
@pytest.fixture(scope="session")
def group(pytestconfig):
return {"id": load_variable("group_id", pytestconfig.cache), "type": ThreadType.GROUP}
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()])
])
@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}
"none": {"id": "0", "type": ThreadType.GROUP},
}[request.param]
@@ -111,14 +115,14 @@ def compare(client, thread):
def message_with_mentions(request, client, client2, group):
text = "Hi there ["
mentions = []
if 'me' in request.param:
if "me" in request.param:
mentions.append(Mention(thread_id=client.uid, offset=len(text), length=2))
text += "me, "
if 'other' in request.param:
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:
if "group" in request.param:
mentions.append(Mention(thread_id=group["id"], offset=len(text), length=5))
text += "group, "
text += "nothing]"

View File

@@ -48,7 +48,9 @@ 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)
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]
@@ -58,7 +60,9 @@ 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)
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]

View File

@@ -9,17 +9,17 @@ from utils import random_hex, subset
from time import time
@pytest.fixture(scope="module", params=[
@pytest.fixture(
scope="module",
params=[
Plan(int(time()) + 100, random_hex()),
pytest.param(
Plan(int(time()), random_hex()),
marks=[pytest.mark.xfail(raises=FBchatFacebookError)]
marks=[pytest.mark.xfail(raises=FBchatFacebookError)],
),
pytest.param(
Plan(0, None),
marks=[pytest.mark.xfail()],
),
])
pytest.param(Plan(0, None), marks=[pytest.mark.xfail()]),
],
)
def plan_data(request, client, user, thread, catch_event, compare):
with catch_event("onPlanCreated") as x:
client.createPlan(request.param, thread["id"])
@@ -50,15 +50,14 @@ def test_fetch_plan_info(client, catch_event, plan_data):
event, plan = plan_data
fetched_plan = client.fetchPlanInfo(plan.uid)
assert subset(
vars(fetched_plan),
time=plan.time,
title=plan.title,
author_id=int(client.uid),
vars(fetched_plan), time=plan.time, title=plan.title, author_id=int(client.uid)
)
@pytest.mark.parametrize("take_part", [False, True])
def test_change_plan_participation(client, thread, catch_event, compare, plan_data, take_part):
def test_change_plan_participation(
client, thread, catch_event, compare, plan_data, take_part
):
event, plan = plan_data
with catch_event("onPlanParticipation") as x:
client.changePlanParticipation(plan, take_part=take_part)
@@ -94,7 +93,11 @@ def test_on_plan_ended(client, thread, catch_event, compare):
with catch_event("onPlanEnded") as x:
client.createPlan(Plan(int(time()) + 120, "Wait for ending"))
x.wait(180)
assert subset(x.res, thread_id=client.uid if thread["type"] == ThreadType.USER else thread["id"], thread_type=thread["type"])
assert subset(
x.res,
thread_id=client.uid if thread["type"] == ThreadType.USER else thread["id"],
thread_type=thread["type"],
)
# createPlan(self, plan, thread_id=None)

View File

@@ -8,28 +8,40 @@ from fbchat.models import Poll, PollOption, ThreadType
from utils import random_hex, subset
@pytest.fixture(scope="module", params=[
@pytest.fixture(
scope="module",
params=[
Poll(title=random_hex(), options=[]),
Poll(title=random_hex(), options=[
Poll(
title=random_hex(),
options=[
PollOption(random_hex(), vote=True),
PollOption(random_hex(), vote=True),
]),
Poll(title=random_hex(), options=[
],
),
Poll(
title=random_hex(),
options=[
PollOption(random_hex(), vote=False),
PollOption(random_hex(), vote=False),
]),
Poll(title=random_hex(), options=[
],
),
Poll(
title=random_hex(),
options=[
PollOption(random_hex(), vote=True),
PollOption(random_hex(), vote=True),
PollOption(random_hex(), vote=False),
PollOption(random_hex(), vote=False),
PollOption(random_hex()),
PollOption(random_hex()),
]),
],
),
pytest.param(
Poll(title=None, options=[]), marks=[pytest.mark.xfail(raises=ValueError)]
),
])
],
)
def poll_data(request, client1, group, catch_event):
with catch_event("onPollCreated") as x:
client1.createPoll(request.param, thread_id=group["id"])
@@ -45,11 +57,17 @@ def test_create_poll(client1, group, catch_event, poll_data):
thread_id=group["id"],
thread_type=ThreadType.GROUP,
)
assert subset(vars(event["poll"]), title=poll.title, options_count=len(poll.options))
for recv_option in event["poll"].options: # The recieved options may not be the full list
assert subset(
vars(event["poll"]), title=poll.title, options_count=len(poll.options)
)
for recv_option in event[
"poll"
].options: # The recieved options may not be the full list
old_option, = list(filter(lambda o: o.text == recv_option.text, poll.options))
voters = [client1.uid] if old_option.vote else []
assert subset(vars(recv_option), voters=voters, votes_count=len(voters), vote=False)
assert subset(
vars(recv_option), voters=voters, votes_count=len(voters), vote=False
)
def test_fetch_poll_options(client1, group, catch_event, poll_data):
@@ -66,7 +84,11 @@ def test_update_poll_vote(client1, group, catch_event, poll_data):
re_vote_ids = [o.uid for o in options[0 : len(options) : 2] if o.vote]
new_options = [random_hex(), random_hex()]
with catch_event("onPollVoted") as x:
client1.updatePollVote(event["poll"].uid, option_ids=new_vote_ids + re_vote_ids, new_options=new_options)
client1.updatePollVote(
event["poll"].uid,
option_ids=new_vote_ids + re_vote_ids,
new_options=new_options,
)
assert subset(
x.res,
@@ -74,8 +96,12 @@ def test_update_poll_vote(client1, group, catch_event, poll_data):
thread_id=group["id"],
thread_type=ThreadType.GROUP,
)
assert subset(vars(x.res["poll"]), title=poll.title, options_count=len(options + new_options))
assert subset(
vars(x.res["poll"]), title=poll.title, options_count=len(options + new_options)
)
for o in new_vote_ids:
assert o in x.res["added_options"]
assert len(x.res["added_options"]) == len(new_vote_ids) + len(new_options)
assert set(x.res["removed_options"]) == set(o.uid for o in options if o.vote and o.uid not in re_vote_ids)
assert set(x.res["removed_options"]) == set(
o.uid for o in options if o.vote and o.uid not in re_vote_ids
)

View File

@@ -38,7 +38,12 @@ def test_send_mentions(client, catch_event, compare, message_with_mentions):
mid = client.send(message_with_mentions)
assert compare(x, mid=mid, message=message_with_mentions.text)
assert subset(vars(x.res["message_object"]), uid=mid, author=client.uid, text=message_with_mentions.text)
assert subset(
vars(x.res["message_object"]),
uid=mid,
author=client.uid,
text=message_with_mentions.text,
)
# The mentions are not ordered by offset
for m in x.res["message_object"].mentions:
assert vars(m) in [vars(x) for x in message_with_mentions.mentions]
@@ -76,7 +81,15 @@ def test_send_images(client, catch_event, compare, method_name, url):
def test_send_local_files(client, catch_event, compare):
files = ["image.png", "image.jpg", "image.gif", "file.json", "file.txt", "audio.mp3", "video.mp4"]
files = [
"image.png",
"image.jpg",
"image.gif",
"file.json",
"file.txt",
"audio.mp3",
"video.mp4",
]
text = "Files sent locally"
with catch_event("onMessage") as x:
mid = client.sendLocalFiles(
@@ -95,7 +108,10 @@ def test_send_remote_files(client, catch_event, compare):
text = "Files sent from remote"
with catch_event("onMessage") as x:
mid = client.sendRemoteFiles(
["https://github.com/carpedm20/fbchat/raw/master/tests/{}".format(f) for f in files],
[
"https://github.com/carpedm20/fbchat/raw/master/tests/{}".format(f)
for f in files
],
message=Message(text),
)
@@ -104,6 +120,6 @@ def test_send_remote_files(client, catch_event, compare):
assert len(x.res["message_object"].attachments) == len(files)
@pytest.mark.parametrize('wave_first', [True, False])
@pytest.mark.parametrize("wave_first", [True, False])
def test_wave(client, wave_first):
client.wave(wave_first)

View File

@@ -9,4 +9,4 @@ def test_catch_event(client2, catch_event):
mid = "test"
with catch_event("onMessage") as x:
client2.onMessage(mid=mid)
assert x.res['mid'] == mid
assert x.res["mid"] == mid

View File

@@ -67,14 +67,19 @@ def test_change_nickname(client, client_all, catch_event, compare):
assert compare(x, changed_for=client_all.uid, new_nickname=nickname)
@pytest.mark.parametrize("emoji", [
@pytest.mark.parametrize(
"emoji",
[
"😀",
"😂",
"😕",
"😍",
pytest.param("🙃", marks=[pytest.mark.xfail(raises=FBchatFacebookError)]),
pytest.param("not an emoji", marks=[pytest.mark.xfail(raises=FBchatFacebookError)]),
])
pytest.param(
"not an emoji", marks=[pytest.mark.xfail(raises=FBchatFacebookError)]
),
],
)
def test_change_emoji(client, catch_event, compare, emoji):
with catch_event("onEmojiChange") as x:
client.changeThreadEmoji(emoji)
@@ -85,7 +90,9 @@ def test_change_image_local(client1, group, catch_event):
url = path.join(path.dirname(__file__), "resources", "image.png")
with catch_event("onImageChange") as x:
image_id = client1.changeGroupImageLocal(url, group["id"])
assert subset(x.res, new_image=image_id, author_id=client1.uid, thread_id=group["id"])
assert subset(
x.res, new_image=image_id, author_id=client1.uid, thread_id=group["id"]
)
# To be changed when merged into master
@@ -93,7 +100,9 @@ def test_change_image_remote(client1, group, catch_event):
url = "https://github.com/carpedm20/fbchat/raw/master/tests/image.png"
with catch_event("onImageChange") as x:
image_id = client1.changeGroupImageRemote(url, group["id"])
assert subset(x.res, new_image=image_id, author_id=client1.uid, thread_id=group["id"])
assert subset(
x.res, new_image=image_id, author_id=client1.uid, thread_id=group["id"]
)
@pytest.mark.parametrize(
@@ -126,7 +135,7 @@ def test_typing_status(client, catch_event, compare, status):
assert compare(x, status=status)
@pytest.mark.parametrize('require_admin_approval', [True, False])
@pytest.mark.parametrize("require_admin_approval", [True, False])
def test_change_approval_mode(client1, group, catch_event, require_admin_approval):
with catch_event("onApprovalModeChange") as x:
client1.changeGroupApprovalMode(require_admin_approval, group["id"])
@@ -138,6 +147,7 @@ def test_change_approval_mode(client1, group, catch_event, require_admin_approva
thread_id=group["id"],
)
@pytest.mark.parametrize("mute_time", [0, 10, 100, 1000, -1])
def test_mute_thread(client, mute_time):
assert client.muteThread(mute_time)

View File

@@ -106,7 +106,7 @@ def load_client(n, cache):
client = Client(
load_variable("client{}_email".format(n), cache),
load_variable("client{}_password".format(n), cache),
user_agent='Mozilla/5.0 (Windows NT 6.3; WOW64; ; NCT50_AAP285C84A1328) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2311.90 Safari/537.36',
user_agent="Mozilla/5.0 (Windows NT 6.3; WOW64; ; NCT50_AAP285C84A1328) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2311.90 Safari/537.36",
session_cookies=cache.get("client{}_session".format(n), None),
max_tries=1,
)