Compare commits

..

89 Commits

Author SHA1 Message Date
Mads Marquart
e6bc5bbab3 Version up, thanks to @kapi2289 and @2FWAH 2019-01-31 20:20:17 +01:00
Mads Marquart
de5f3a9d9e Merge branch 'pr/300' 2019-01-31 20:13:27 +01:00
Mads Marquart
7f0da012c2 Few nitpicky fixes 2019-01-31 20:12:59 +01:00
Mads Marquart
76ecbf5eb0 Merge branch 'pr/325' 2019-01-31 19:57:22 +01:00
Mads Marquart
06881a4c70 Add formatMentions docstring 2019-01-31 19:56:35 +01:00
Mads Marquart
c14fdd82db Merge branch 'pr/338' 2019-01-31 19:29:54 +01:00
Mads Marquart
b1a02ad930 Merge pull request #342 from kapi2289/quick_replies
[Feature] Quick replies
2019-01-31 19:26:03 +01:00
Mads Marquart
2b580c60e9 Readd deprecated markAlive parameter 2019-01-31 19:23:46 +01:00
Mads Marquart
27ffba3b14 Fix a few isinstance checks 2019-01-31 19:21:52 +01:00
Mads Marquart
fb7bf437ba Merge pull request #384 from carpedm20/github-releases-ci
Automatic GitHub Releases
2019-01-25 20:01:37 +01:00
Mads Marquart
d8baf0b9e7 Put automatic GitHub releases in the draft state
This is done so that I can edit the description as needed, before publishing
2019-01-25 19:35:20 +01:00
Mads Marquart
a6945fe880 Merge branch 'disable-online-tests' 2019-01-25 19:18:03 +01:00
Mads Marquart
6ff77dd8c7 Merge pull request #382 from carpedm20/flit
Use `flit` as our build system
2019-01-25 19:14:04 +01:00
Mads Marquart
1d925a608b Update pypy version to 3.5 2019-01-25 18:53:14 +01:00
Mads Marquart
646669ca75 Add Github Releases deployment 2019-01-25 18:49:56 +01:00
Mads Marquart
0ec2baaa83 Add Python 3.7 testing 2019-01-25 18:49:35 +01:00
Mads Marquart
5abaaefd1c Disable Travis online tests 2019-01-25 18:49:08 +01:00
Mads Marquart
687afea0f2 Pin minimum pytest version to fix tests 2019-01-25 17:45:15 +01:00
Mads Marquart
7398d4fa2b Use --python option to properly install the package under Python 2.7 2019-01-25 17:14:14 +01:00
Mads Marquart
d73c8c3627 Fix travis setup for running flit under Python 2.7 2019-01-25 17:05:35 +01:00
Mads Marquart
f921b91c5b Make travis use flit 2019-01-25 16:43:22 +01:00
Mads Marquart
8ed3c1b159 Use flit instead of setuptools
Mostly just a simple move from `setup.cfg` -> `pyproject.toml`. Had to reformat the description in `__init__` a little though.
2019-01-25 16:36:09 +01:00
Mads Marquart
4f947cdbb5 Version up, thanks to @kapi2289 and @kaushalvivek 2019-01-25 16:01:47 +01:00
Mads Marquart
ec6c29052a Merge pull request #371 from carpedm20/fix-enums
Fix `ThreadColor` and `MessageReaction` enums
2019-01-24 22:42:41 +01:00
Mads Marquart
6b117502f3 Merge branch 'master' into fix-enums 2019-01-24 22:40:44 +01:00
Kacper Ziubryniewicz
a367aa0b31 Replying on location quick replies 2019-01-05 20:40:45 +01:00
Kacper Ziubryniewicz
7f6843df55 Better quick reply types 2019-01-05 20:06:28 +01:00
Kacper Ziubryniewicz
4b485d54b6 Merge remote-tracking branch 'origin/master' into quick_replies 2019-01-05 19:29:32 +01:00
Kacper Ziubryniewicz
e80a040db4 Deprecate markAlive parameter in doOneListen and _pullMessage 2019-01-05 18:48:40 +01:00
Kacper Ziubryniewicz
c357fd085b Better listening for buddylist overlay and chatbox presence 2019-01-05 18:36:48 +01:00
Kacper Ziubryniewicz
d0c5f29b0a Fixed getting active status 2019-01-05 18:24:23 +01:00
Mads Marquart
3e7b20c379 Merge pull request #377 from kapi2289/fix-fbchatexception
Fixed typos in FBchatException
2019-01-05 18:20:38 +01:00
Kacper Ziubryniewicz
f4a997c0ef Fixed typos in FBchatException 2019-01-05 17:55:54 +01:00
Kacper Ziubryniewicz
102e74bb63 Merge remote-tracking branch 'origin/master' into active_status 2019-01-05 17:46:27 +01:00
Mads Marquart
84fa15e44c Merge pull request #333 from kapi2289/extensible_attachments
[Feature] Extensible attachments
2019-01-04 21:06:11 +01:00
Kacper Ziubryniewicz
7b8ecf8fe3 Changed deleted to unsent 2019-01-04 20:02:00 +01:00
Kacper Ziubryniewicz
79ebf920ea More on responding to quick replies 2019-01-03 23:28:23 +01:00
Kacper Ziubryniewicz
0d05d42f70 getPhoneNumbers and getEmails methods 2019-01-03 22:54:47 +01:00
Kacper Ziubryniewicz
95989b6da7 Merge branch 'master' into extensible_attachments 2018-12-23 14:58:03 +01:00
Kacper Ziubryniewicz
22e57f99a1 deleted attribute of Message
and batter handling of deleted (unsended) messages
2018-12-23 14:56:27 +01:00
Kacper Ziubryniewicz
b9d29c0417 Removed addReaction, removeReaction, _react
(and undeprecated `reactToMessage`)
2018-12-23 14:45:17 +01:00
Kacper Ziubryniewicz
edc33db9e8 Few fixes in quick replies 2018-12-23 14:36:26 +01:00
Mads Marquart
45d8b45d96 Fix enum_extend_if_invalid warning 2018-12-12 23:22:08 +01:00
Mads Marquart
b6a6d7dc68 Move enum_extend_if_invalid to utils.py 2018-12-12 23:06:16 +01:00
Mads Marquart
c57b84cd0b Refactor enum extending 2018-12-12 23:04:26 +01:00
Mads Marquart
78e7841b5e Extend MessageReaction when encountering unknown values 2018-12-12 22:53:23 +01:00
Mads Marquart
e41d981449 Extend ThreadColor when encountering unknown values 2018-12-12 22:44:19 +01:00
Mads Marquart
381227af66 Make use aenum instead of the default enum 2018-12-12 22:39:31 +01:00
Mads Marquart
2f8d0728ba Merge pull request #366 from kaushalvivek/master
Fix for issue #365
2018-12-10 21:16:57 +01:00
kaushalvivek
13bfc5f2f9 Fix for search limit 2018-12-10 14:46:04 +05:30
Kacper Ziubryniewicz
d4446280c7 Detecting when someone unsends a message 2018-12-09 15:27:01 +01:00
Kacper Ziubryniewicz
861f17bc4d Added DeletedMessage attachment 2018-12-09 14:55:10 +01:00
Kacper Ziubryniewicz
41bbe18e3d Unsending messages 2018-12-09 14:36:23 +01:00
Kacper Ziubryniewicz
5f9c357a15 Fixed graphql and added method for replying on quick replies 2018-12-09 01:07:33 +01:00
Kacper Ziubryniewicz
c089298f46 Sending new quick replies 2018-12-09 00:57:58 +01:00
Kacper Ziubryniewicz
be968e0caa New models for quick replies 2018-12-09 00:32:44 +01:00
Vivek Kaushal
d32b7b612a Fix for issue #365 2018-12-07 21:26:48 +05:30
Kacper Ziubryniewicz
8739318101 Sending voice clips 2018-10-30 22:24:47 +01:00
Kacper Ziubryniewicz
1ac569badd Sending pinned or current location 2018-10-30 22:21:05 +01:00
Kacper Ziubryniewicz
e38f891693 Active status fixes 2018-10-30 21:48:55 +01:00
Kacper Ziubryniewicz
b7ea8e6001 New sendLocation method 2018-09-29 13:48:08 +02:00
Kacper Ziubryniewicz
b0bf5ba8e0 Update graphql.py 2018-09-29 13:42:11 +02:00
Kacper Ziubryniewicz
8169a5f776 Changed LocationAttachment 2018-09-29 13:40:38 +02:00
Kacper Ziubryniewicz
492465a525 Update graphql.py 2018-09-25 18:00:44 +02:00
Kacper Ziubryniewicz
f185e44f93 Update models.py 2018-09-25 17:59:16 +02:00
Kacper Ziubryniewicz
5f2c318baf Sending quick replies 2018-09-24 21:04:21 +02:00
Kacper Ziubryniewicz
531a5b77d0 GraphQL method for quick replies 2018-09-24 20:57:19 +02:00
Kacper Ziubryniewicz
f9245cdfed New model and Message attribute
New `QuickReply` model and `quick_replies` attribute of `Message` model.
2018-09-24 20:54:25 +02:00
Kacper Ziubryniewicz
47ea88e025 Read commit description
- Fixed `onImageChange` documentation and added missing `msg` parameter
- Moved `on` methods to the right place
- Added changing client active status while listening
- Added fetching friends' active status
2018-09-22 21:52:40 +02:00
Kacper Ziubryniewicz
345a473ee0 ActiveStatus model 2018-09-22 21:34:44 +02:00
Kacper Ziubryniewicz
c6dc432d06 Move on methods to the right place 2018-09-22 20:39:41 +02:00
2FWAH
af3bd55535 Add basic test for fetchThreads 2018-09-21 19:43:39 +02:00
2FWAH
5fa1d86191 Add before, after and limit parameters to fetchThreads 2018-09-21 19:12:46 +02:00
2FWAH
d4859b675a Fix ident for _forcedFetch 2018-09-21 17:36:16 +02:00
2FWAH
9aa427031e Merge from upstream and solve conflict in fbchat/client.py 2018-09-21 17:29:58 +02:00
Kacper Ziubryniewicz
9e8fe7bc1e Fix Python 2.7 compability 2018-09-15 11:34:16 +02:00
Kacper Ziubryniewicz
90813c959d Added get_url_parameters util method 2018-09-15 11:21:35 +02:00
Kacper Ziubryniewicz
940a65954c Read commit description
Added:
- Detecting extensible attachments
- Fetching live user location
- New methods for message reacting
- New `on` methods: `onReactionAdded`, `onReactionRemoved`, `onBlock`, `onUnblock`, `onLiveLocation`
- Fixed `size` of attachments
2018-09-12 17:52:38 +02:00
Kacper Ziubryniewicz
9b4e753a79 Added graphql methods for extensible attachments 2018-09-12 17:48:35 +02:00
Kacper Ziubryniewicz
e0be9029e4 Added extensible attachments models 2018-09-12 17:48:00 +02:00
Kacper Ziubryniewicz
aa3faca246 Added formatMentions method 2018-08-30 15:57:16 +02:00
2FWAH
2edb95dfdd Fetch missing users in a single request 2018-06-12 08:38:02 +02:00
2FWAH
e0bb9960fb Check if list is empty with if instead of len() 2018-06-12 08:15:53 +02:00
2FWAH
71608845c0 Use snake case convention 2018-06-12 07:55:16 +02:00
2FWAH
0048e82151 Fix typo in fetchAllUsersFromThreads 2018-06-07 21:58:00 +02:00
2FWAH
0767ef4902 Add fetchAllUsersFromThreads
Add a method to get all users involved in threads (given as a parameter)
2018-06-01 23:27:34 +02:00
2FWAH
abe3357e67 Explicit parameter thread_location 2018-06-01 23:08:03 +02:00
2FWAH
19457efe9b Fix call to fetchThreadList
Use "self" instead of "client"
2018-06-01 23:06:02 +02:00
2FWAH
487a2eb3e3 Add fetchThreads method
Add a method to get all threads in Location (INBOX, ARCHIVED...)
2018-06-01 22:59:56 +02:00
16 changed files with 997 additions and 266 deletions

View File

@@ -1,90 +1,50 @@
sudo: false sudo: false
language: python language: python
conditions: v1 python: 3.6
# There are two accounts made specifically for Travis, and the passwords are really only encrypted for obscurity cache: pip
# The global env variables `client1_email`, `client1_password`, `client2_email`, `client2_password` and `group_id`
# are set on the Travis Settings page
# The tests are run with `Limit concurrent jobs = 1`, since the tests can't use the clients simultaneously before_install: pip install flit
install: flit install --deps production --extras test
install: script: pytest -m offline
- pip install -U -r requirements.txt
- pip install -U -r dev-requirements.txt
cache:
pip: true
# Pytest caching is disabled, since TravisCI instances have different public IPs. Facebook doesn't like that,
# and redirects you to the url `/checkpoint/block`, where you have to change the account's password
# directories:
# - .pytest_cache
jobs: jobs:
include: include:
# The tests are split into online and offline versions. - python: 2.7
# The online tests are only run against the master branch. before_install:
# Because: - sudo apt-get -y install python3-pip python3-setuptools
# Travis caching is per-branch and per-job, so even though we cache the Facebook sessions via. `.pytest_cache` - sudo pip3 install flit
# and in `tests.utils.load_client`, we need 6 new sessions per branch. This is usually the point where Facebook install: flit install --python python --deps production --extras test
# starts complaining, and we have to manually fix it - python: 3.4
- python: 3.5
- python: 3.6
- python: 3.7
dist: xenial
sudo: required
- python: pypy3.5
- &test-online - stage: deploy
if: (branch = master OR tag IS present) AND type != pull_request name: Github Releases
stage: online tests if: tag IS present
script: scripts/travis-online
# Run online tests in all the supported python versions
python: 2.7
- <<: *test-online
python: 3.4
- <<: *test-online
python: 3.5
- <<: *test-online
python: 3.6
- <<: *test-online
python: pypy
# Run the expensive tests, with the python version most likely to break, aka. 2
- <<: *test-online
# Only run if the commit message includes [ci all] or [all ci]
if: commit_message =~ /\[ci\s+all\]|\[all\s+ci\]/
python: 2.7
env: PYTEST_ADDOPTS='-m expensive'
- &test-offline
# Ideally, it'd be nice to run the offline tests in every build, but since we can't run jobs concurrently (yet),
# we'll disable them when they're not needed, and include them inside the online tests instead
if: not ((branch = master OR tag IS present) AND type != pull_request)
stage: offline tests
script: scripts/travis-offline
# Run offline tests in all the supported python versions
python: 2.7
- <<: *test-offline
python: 3.4
- <<: *test-offline
python: 3.5
- <<: *test-offline
python: 3.6
- <<: *test-offline
python: 3.6
- <<: *test-offline
python: pypy
# Deploy to PyPI
- &deploy
stage: deploy
if: branch = master AND tag IS present
install: skip install: skip
script: flit build
deploy: deploy:
provider: pypi provider: releases
user: madsmtm api_key: $GITHUB_OAUTH_TOKEN
password: file_glob: true
secure: "VA0MLSrwIW/T2KjMwjLZCzrLHw8pJT6tAvb48t7qpBdm8x192hax61pz1TaBZoJvlzyBPFKvluftuclTc7yEFwzXe7Gjqgd/ODKZl/wXDr36hQ7BBOLPZujdwmWLvTzMh3eJZlvkgcLCzrvK3j2oW8cM/+FZeVi/5/FhVuJ4ofs=" file: dist/*
distributions: sdist bdist_wheel skip_cleanup: true
skip_existing: true draft: true
on:
tags: true
# We need the bdist_wheels from both Python 2 and 3 - stage: deploy
python: 3.6 name: PyPI
- <<: *deploy if: tag IS present
python: 2.7 install: skip
script: skip
deploy:
provider: script
script: flit publish
on:
tags: true

View File

@@ -1,3 +0,0 @@
include LICENSE
include CONTRIBUTING.rst
include README.rst

View File

@@ -5,9 +5,9 @@ fbchat: Facebook Chat (Messenger) for Python
:target: https://github.com/carpedm20/fbchat/tree/master/LICENSE :target: https://github.com/carpedm20/fbchat/tree/master/LICENSE
:alt: License: BSD 3-Clause :alt: License: BSD 3-Clause
.. image:: https://img.shields.io/badge/python-2.7%2C%203.4%2C%203.5%2C%203.6%20pypy-blue.svg .. image:: https://img.shields.io/badge/python-2.7%2C%203.4%2C%203.5%2C%203.6%203.7%20pypy-blue.svg
:target: https://pypi.python.org/pypi/fbchat :target: https://pypi.python.org/pypi/fbchat
:alt: Supported python versions: 2.7, 3.4, 3.5, 3.6 and pypy :alt: Supported python versions: 2.7, 3.4, 3.5, 3.6, 3.7 and pypy
.. image:: https://readthedocs.org/projects/fbchat/badge/?version=master .. image:: https://readthedocs.org/projects/fbchat/badge/?version=master
:target: https://fbchat.readthedocs.io :target: https://fbchat.readthedocs.io
@@ -27,17 +27,18 @@ or jump right into the code by viewing the `examples <https://github.com/carpedm
Installation: Installation:
.. code-block:: console .. code-block::
$ pip install fbchat $ pip install fbchat
You can also install from source, by using `setuptools` (You need at least version 30.3.0): You can also install from source, by using `flit`:
.. code-block:: console .. code-block::
$ pip install flit
$ git clone https://github.com/carpedm20/fbchat.git $ git clone https://github.com/carpedm20/fbchat.git
$ cd fbchat $ cd fbchat
$ python setup.py install $ flit install
Maintainer Maintainer

View File

@@ -1,2 +0,0 @@
pytest
six

View File

@@ -1,12 +1,7 @@
# -*- coding: UTF-8 -*- # -*- coding: UTF-8 -*-
"""Facebook Chat (Messenger) for Python
""" :copyright: (c) 2015 - 2019 by Taehoon Kim
fbchat
~~~~~~
Facebook Chat (Messenger) for Python
:copyright: (c) 2015 - 2018 by Taehoon Kim
:license: BSD 3-Clause, see LICENSE for more details. :license: BSD 3-Clause, see LICENSE for more details.
""" """
@@ -15,10 +10,10 @@ from __future__ import unicode_literals
from .client import * from .client import *
__title__ = 'fbchat' __title__ = 'fbchat'
__version__ = '1.4.2' __version__ = '1.6.0'
__description__ = 'Facebook Chat (Messenger) for Python' __description__ = 'Facebook Chat (Messenger) for Python'
__copyright__ = 'Copyright 2015 - 2018 by Taehoon Kim' __copyright__ = 'Copyright 2015 - 2019 by Taehoon Kim'
__license__ = 'BSD 3-Clause' __license__ = 'BSD 3-Clause'
__author__ = 'Taehoon Kim; Moreels Pieter-Jan; Mads Marquart' __author__ = 'Taehoon Kim; Moreels Pieter-Jan; Mads Marquart'

View File

@@ -62,6 +62,8 @@ class Client(object):
self.default_thread_id = None self.default_thread_id = None
self.default_thread_type = None self.default_thread_type = None
self.req_url = ReqUrl() self.req_url = ReqUrl()
self._markAlive = True
self._buddylist = dict()
if not user_agent: if not user_agent:
user_agent = choice(USER_AGENTS) user_agent = choice(USER_AGENTS)
@@ -474,6 +476,82 @@ class Client(object):
})) }))
return j return j
def fetchThreads(self, thread_location, before=None, after=None, limit=None):
"""
Get all threads in thread_location.
Threads will be sorted from newest to oldest.
:param thread_location: models.ThreadLocation: INBOX, PENDING, ARCHIVED or OTHER
:param before: Fetch only thread before this epoch (in ms) (default all threads)
:param after: Fetch only thread after this epoch (in ms) (default all threads)
:param limit: The max. amount of threads to fetch (default all threads)
:return: :class:`models.Thread` objects
:rtype: list
:raises: FBchatException if request failed
"""
threads = []
last_thread_timestamp = None
while True:
# break if limit is exceeded
if limit and len(threads) >= limit:
break
# fetchThreadList returns at max 20 threads before last_thread_timestamp (included)
candidates = self.fetchThreadList(before=last_thread_timestamp,
thread_location=thread_location
)
if len(candidates) > 1:
threads += candidates[1:]
else: # End of threads
break
last_thread_timestamp = threads[-1].last_message_timestamp
# FB returns a sorted list of threads
if (before is not None and int(last_thread_timestamp) > before) or \
(after is not None and int(last_thread_timestamp) < after):
break
# Return only threads between before and after (if set)
if before is not None or after is not None:
for t in threads:
last_message_timestamp = int(t.last_message_timestamp)
if (before is not None and last_message_timestamp > before) or \
(after is not None and last_message_timestamp < after):
threads.remove(t)
if limit and len(threads) > limit:
return threads[:limit]
return threads
def fetchAllUsersFromThreads(self, threads):
"""
Get all users involved in threads.
:param threads: models.Thread: List of threads to check for users
:return: :class:`models.User` objects
:rtype: list
:raises: FBchatException if request failed
"""
users = []
users_to_fetch = [] # It's more efficient to fetch all users in one request
for thread in threads:
if thread.type == ThreadType.USER:
if thread.uid not in [user.uid for user in users]:
users.append(thread)
elif thread.type == ThreadType.GROUP:
for user_id in thread.participants:
if user_id not in [user.uid for user in users] and user_id not in users_to_fetch:
users_to_fetch.append(user_id)
else:
pass
for user_id, user in self.fetchUserInfo(*users_to_fetch).items():
users.append(user)
return users
def fetchAllUsers(self): def fetchAllUsers(self):
""" """
Gets all users the client is currently chatting with Gets all users the client is currently chatting with
@@ -502,7 +580,7 @@ class Client(object):
return users return users
def searchForUsers(self, name, limit=1): def searchForUsers(self, name, limit=10):
""" """
Find and get user by his/her name Find and get user by his/her name
@@ -517,7 +595,7 @@ class Client(object):
return [graphql_to_user(node) for node in j[name]['users']['nodes']] return [graphql_to_user(node) for node in j[name]['users']['nodes']]
def searchForPages(self, name, limit=1): def searchForPages(self, name, limit=10):
""" """
Find and get page by its name Find and get page by its name
@@ -531,7 +609,7 @@ class Client(object):
return [graphql_to_page(node) for node in j[name]['pages']['nodes']] return [graphql_to_page(node) for node in j[name]['pages']['nodes']]
def searchForGroups(self, name, limit=1): def searchForGroups(self, name, limit=10):
""" """
Find and get group thread by its name Find and get group thread by its name
@@ -546,7 +624,7 @@ class Client(object):
return [graphql_to_group(node) for node in j['viewer']['groups']['nodes']] return [graphql_to_group(node) for node in j['viewer']['groups']['nodes']]
def searchForThreads(self, name, limit=1): def searchForThreads(self, name, limit=10):
""" """
Find and get a thread by its name Find and get a thread by its name
@@ -927,14 +1005,14 @@ class Client(object):
:type image_id: str :type image_id: str
:return: An url where you can download the original image :return: An url where you can download the original image
:rtype: str :rtype: str
:raises: FBChatException if request failed :raises: FBchatException if request failed
""" """
image_id = str(image_id) image_id = str(image_id)
j = check_request(self._get(ReqUrl.ATTACHMENT_PHOTO, query={'photo_id': str(image_id)})) j = check_request(self._get(ReqUrl.ATTACHMENT_PHOTO, query={'photo_id': str(image_id)}))
url = get_jsmods_require(j, 3) url = get_jsmods_require(j, 3)
if url is None: if url is None:
raise FBChatException('Could not fetch image url from: {}'.format(j)) raise FBchatException('Could not fetch image url from: {}'.format(j))
return url return url
def fetchMessageInfo(self, mid, thread_id=None): def fetchMessageInfo(self, mid, thread_id=None):
@@ -945,7 +1023,7 @@ class Client(object):
:param thread_id: User/Group ID to get message info from. See :ref:`intro_threads` :param thread_id: User/Group ID to get message info from. See :ref:`intro_threads`
:return: :class:`models.Message` object :return: :class:`models.Message` object
:rtype: models.Message :rtype: models.Message
:raises: FBChatException if request failed :raises: FBchatException if request failed
""" """
thread_id, thread_type = self._getThread(thread_id, None) thread_id, thread_type = self._getThread(thread_id, None)
message_info = self._forcedFetch(thread_id, mid).get("message") message_info = self._forcedFetch(thread_id, mid).get("message")
@@ -958,7 +1036,7 @@ class Client(object):
:param poll_id: Poll ID to fetch from :param poll_id: Poll ID to fetch from
:rtype: list :rtype: list
:raises: FBChatException if request failed :raises: FBchatException if request failed
""" """
data = { data = {
"question_id": poll_id "question_id": poll_id
@@ -975,7 +1053,7 @@ class Client(object):
:param plan_id: Plan ID to fetch from :param plan_id: Plan ID to fetch from
:return: :class:`models.Plan` object :return: :class:`models.Plan` object
:rtype: models.Plan :rtype: models.Plan
:raises: FBChatException if request failed :raises: FBchatException if request failed
""" """
data = { data = {
"event_reminder_id": plan_id "event_reminder_id": plan_id
@@ -984,6 +1062,44 @@ class Client(object):
plan = graphql_to_plan(j["payload"]) plan = graphql_to_plan(j["payload"])
return plan return plan
def _getPrivateData(self):
j = self.graphql_request(GraphQL(doc_id='1868889766468115'))
return j['viewer']
def getPhoneNumbers(self):
"""
Fetches a list of user phone numbers.
:return: List of phone numbers
:rtype: list
"""
data = self._getPrivateData()
return [j['phone_number']['universal_number'] for j in data['user']['all_phones']]
def getEmails(self):
"""
Fetches a list of user emails.
:return: List of emails
:rtype: list
"""
data = self._getPrivateData()
return [j['display_email'] for j in data['all_emails']]
def getUserActiveStatus(self, user_id):
"""
Gets friend active status as an :class:`models.ActiveStatus` object.
Returns `None` if status isn't known.
.. warning::
Only works when listening.
:param user_id: ID of the user
:return: Given user active status
:rtype: models.ActiveStatus
"""
return self._buddylist.get(str(user_id))
""" """
END FETCH METHODS END FETCH METHODS
""" """
@@ -1040,6 +1156,25 @@ class Client(object):
if message.sticker: if message.sticker:
data['sticker_id'] = message.sticker.uid data['sticker_id'] = message.sticker.uid
if message.quick_replies:
xmd = {"quick_replies": []}
for quick_reply in message.quick_replies:
q = dict()
q["content_type"] = quick_reply._type
q["payload"] = quick_reply.payload
q["external_payload"] = quick_reply.external_payload
q["data"] = quick_reply.data
if quick_reply.is_response:
q["ignore_for_webhook"] = False
if isinstance(quick_reply, QuickReplyText):
q["title"] = quick_reply.title
if not isinstance(quick_reply, QuickReplyLocation):
q["image_url"] = quick_reply.image_url
xmd["quick_replies"].append(q)
if len(message.quick_replies) == 1 and message.quick_replies[0].is_response:
xmd["quick_replies"] = xmd["quick_replies"][0]
data['platform_xmd'] = json.dumps(xmd)
return data return data
def _doSendRequest(self, data, get_thread_id=False): def _doSendRequest(self, data, get_thread_id=False):
@@ -1111,7 +1246,86 @@ class Client(object):
data['specific_to_list[0]'] = "fbid:{}".format(thread_id) data['specific_to_list[0]'] = "fbid:{}".format(thread_id)
return self._doSendRequest(data) return self._doSendRequest(data)
def _upload(self, files): def quickReply(self, quick_reply, payload=None, thread_id=None, thread_type=None):
"""
Replies to a chosen quick reply
:param quick_reply: Quick reply to reply to
:param payload: Optional answer to the quick reply
:param thread_id: User/Group ID to send to. See :ref:`intro_threads`
:param thread_type: See :ref:`intro_threads`
:type quick_reply: models.QuickReply
:type thread_type: models.ThreadType
:return: :ref:`Message ID <intro_message_ids>` of the sent message
:raises: FBchatException if request failed
"""
quick_reply.is_response = True
if isinstance(quick_reply, QuickReplyText):
return self.send(Message(text=quick_reply.title, quick_replies=[quick_reply]))
elif isinstance(quick_reply, QuickReplyLocation):
if not isinstance(payload, LocationAttachment): raise ValueError("Payload must be an instance of `fbchat.models.LocationAttachment`")
return self.sendLocation(payload, thread_id=thread_id, thread_type=thread_type)
elif isinstance(quick_reply, QuickReplyEmail):
if not payload: payload = self.getEmails()[0]
quick_reply.external_payload = quick_reply.payload
quick_reply.payload = payload
return self.send(Message(text=payload, quick_replies=[quick_reply]))
elif isinstance(quick_reply, QuickReplyPhoneNumber):
if not payload: payload = self.getPhoneNumbers()[0]
quick_reply.external_payload = quick_reply.payload
quick_reply.payload = payload
return self.send(Message(text=payload, quick_replies=[quick_reply]))
def unsend(self, mid):
"""
Unsends a message (removes for everyone)
:param mid: :ref:`Message ID <intro_message_ids>` of the message to unsend
"""
data = {
'message_id': mid,
}
r = self._post(self.req_url.UNSEND, data)
r.raise_for_status()
def _sendLocation(self, location, current=True, thread_id=None, thread_type=None):
thread_id, thread_type = self._getThread(thread_id, thread_type)
data = self._getSendData(thread_id=thread_id, thread_type=thread_type)
data['action_type'] = 'ma-type:user-generated-message'
data['location_attachment[coordinates][latitude]'] = location.latitude
data['location_attachment[coordinates][longitude]'] = location.longitude
data['location_attachment[is_current_location]'] = current
return self._doSendRequest(data)
def sendLocation(self, location, thread_id=None, thread_type=None):
"""
Sends a given location to a thread as the user's current location
:param location: Location to send
:param thread_id: User/Group ID to send to. See :ref:`intro_threads`
:param thread_type: See :ref:`intro_threads`
:type location: models.LocationAttachment
:type thread_type: models.ThreadType
:return: :ref:`Message ID <intro_message_ids>` of the sent message
:raises: FBchatException if request failed
"""
self._sendLocation(location=location, current=True, thread_id=thread_id, thread_type=thread_type)
def sendPinnedLocation(self, location, thread_id=None, thread_type=None):
"""
Sends a given location to a thread as a pinned location
:param location: Location to send
:param thread_id: User/Group ID to send to. See :ref:`intro_threads`
:param thread_type: See :ref:`intro_threads`
:type location: models.LocationAttachment
:type thread_type: models.ThreadType
:return: :ref:`Message ID <intro_message_ids>` of the sent message
:raises: FBchatException if request failed
"""
self._sendLocation(location=location, current=False, thread_id=thread_id, thread_type=thread_type)
def _upload(self, files, voice_clip=False):
""" """
Uploads files to Facebook Uploads files to Facebook
@@ -1121,7 +1335,12 @@ class Client(object):
Returns a list of tuples with a file's ID and mimetype Returns a list of tuples with a file's ID and mimetype
""" """
file_dict = {'upload_{}'.format(i): f for i, f in enumerate(files)} file_dict = {'upload_{}'.format(i): f for i, f in enumerate(files)}
j = self._postFile(self.req_url.UPLOAD, files=file_dict, fix_request=True, as_json=True)
data = {
"voice_clip": voice_clip,
}
j = self._postFile(self.req_url.UPLOAD, files=file_dict, query=data, fix_request=True, as_json=True)
if len(j['payload']['metadata']) != len(files): if len(j['payload']['metadata']) != len(files):
raise FBchatException("Some files could not be uploaded: {}, {}".format(j, files)) raise FBchatException("Some files could not be uploaded: {}, {}".format(j, files))
@@ -1165,7 +1384,7 @@ class Client(object):
""" """
Sends local files to a thread Sends local files to a thread
:param file_path: Paths of files to upload and send :param file_paths: Paths of files to upload and send
:param message: Additional message :param message: Additional message
:param thread_id: User/Group ID to send to. See :ref:`intro_threads` :param thread_id: User/Group ID to send to. See :ref:`intro_threads`
:param thread_type: See :ref:`intro_threads` :param thread_type: See :ref:`intro_threads`
@@ -1178,6 +1397,39 @@ class Client(object):
files = self._upload(x) files = self._upload(x)
return self._sendFiles(files=files, message=message, thread_id=thread_id, thread_type=thread_type) return self._sendFiles(files=files, message=message, thread_id=thread_id, thread_type=thread_type)
def sendRemoteVoiceClips(self, clip_urls, message=None, thread_id=None, thread_type=ThreadType.USER):
"""
Sends voice clips from URLs to a thread
:param clip_urls: URLs of clips to upload and send
:param message: Additional message
:param thread_id: User/Group ID to send to. See :ref:`intro_threads`
:param thread_type: See :ref:`intro_threads`
:type thread_type: models.ThreadType
:return: :ref:`Message ID <intro_message_ids>` of the sent files
:raises: FBchatException if request failed
"""
clip_urls = require_list(clip_urls)
files = self._upload(get_files_from_urls(clip_urls), voice_clip=True)
return self._sendFiles(files=files, message=message, thread_id=thread_id, thread_type=thread_type)
def sendLocalVoiceClips(self, clip_paths, message=None, thread_id=None, thread_type=ThreadType.USER):
"""
Sends local voice clips to a thread
:param clip_paths: Paths of clips to upload and send
:param message: Additional message
:param thread_id: User/Group ID to send to. See :ref:`intro_threads`
:param thread_type: See :ref:`intro_threads`
:type thread_type: models.ThreadType
:return: :ref:`Message ID <intro_message_ids>` of the sent files
:raises: FBchatException if request failed
"""
clip_paths = require_list(clip_paths)
with get_files_from_paths(clip_paths) as x:
files = self._upload(x, voice_clip=True)
return self._sendFiles(files=files, message=message, thread_id=thread_id, thread_type=thread_type)
def sendImage(self, image_id, message=None, thread_id=None, thread_type=ThreadType.USER, is_gif=False): def sendImage(self, image_id, message=None, thread_id=None, thread_type=ThreadType.USER, is_gif=False):
""" """
Deprecated. Use :func:`fbchat.Client._sendFiles` instead Deprecated. Use :func:`fbchat.Client._sendFiles` instead
@@ -1482,27 +1734,26 @@ class Client(object):
def reactToMessage(self, message_id, reaction): def reactToMessage(self, message_id, reaction):
""" """
Reacts to a message Reacts to a message, or removes reaction
:param message_id: :ref:`Message ID <intro_message_ids>` to react to :param message_id: :ref:`Message ID <intro_message_ids>` to react to
:param reaction: Reaction emoji to use :param reaction: Reaction emoji to use, if None removes reaction
:type reaction: models.MessageReaction :type reaction: models.MessageReaction or None
:raises: FBchatException if request failed :raises: FBchatException if request failed
""" """
full_data = { data = {
"doc_id": 1491398900900362, "doc_id": 1491398900900362,
"variables": json.dumps({ "variables": json.dumps({
"data": { "data": {
"action": "ADD_REACTION", "action": "ADD_REACTION" if reaction else "REMOVE_REACTION",
"client_mutation_id": "1", "client_mutation_id": "1",
"actor_id": self.uid, "actor_id": self.uid,
"message_id": str(message_id), "message_id": str(message_id),
"reaction": reaction.value "reaction": reaction.value if reaction else None
} }
}) })
} }
self._post(self.req_url.MESSAGE_REACTION, data, fix_request=True, as_json=True)
j = self._post(self.req_url.MESSAGE_REACTION, full_data, fix_request=True, as_json=True)
def createPlan(self, plan, thread_id=None): def createPlan(self, plan, thread_id=None):
""" """
@@ -1742,7 +1993,7 @@ class Client(object):
.. todo:: .. todo::
Documenting this Documenting this
""" """
r = self._post(self.req_url.MARK_SEEN, {"seen_timestamp": 0}) r = self._post(self.req_url.MARK_SEEN, {"seen_timestamp": now()})
return r.ok return r.ok
def friendConnect(self, friend_id): def friendConnect(self, friend_id):
@@ -1970,7 +2221,7 @@ class Client(object):
} }
self._get(self.req_url.PING, data, fix_request=True, as_json=False) self._get(self.req_url.PING, data, fix_request=True, as_json=False)
def _pullMessage(self, markAlive=True): def _pullMessage(self):
"""Call pull api with seq value to get message data.""" """Call pull api with seq value to get message data."""
data = { data = {
@@ -1978,7 +2229,7 @@ class Client(object):
"sticky_token": self.sticky, "sticky_token": self.sticky,
"sticky_pool": self.pool, "sticky_pool": self.pool,
"clientid": self.client_id, "clientid": self.client_id,
'state': 'active' if markAlive else 'offline', 'state': 'active' if self._markAlive else 'offline',
} }
j = self._get(self.req_url.STICKY, data, fix_request=True, as_json=True) j = self._get(self.req_url.STICKY, data, fix_request=True, as_json=True)
@@ -2019,6 +2270,7 @@ class Client(object):
delta = m["delta"] delta = m["delta"]
delta_type = delta.get("type") delta_type = delta.get("type")
delta_class = delta.get("class")
metadata = delta.get("messageMetadata") metadata = delta.get("messageMetadata")
if metadata: if metadata:
@@ -2055,14 +2307,14 @@ class Client(object):
thread_type=thread_type, ts=ts, metadata=metadata, msg=m) thread_type=thread_type, ts=ts, metadata=metadata, msg=m)
# Thread title change # Thread title change
elif delta.get("class") == "ThreadName": elif delta_class == "ThreadName":
new_title = delta["name"] new_title = delta["name"]
thread_id, thread_type = getThreadIdAndThreadType(metadata) thread_id, thread_type = getThreadIdAndThreadType(metadata)
self.onTitleChange(mid=mid, author_id=author_id, new_title=new_title, thread_id=thread_id, self.onTitleChange(mid=mid, author_id=author_id, new_title=new_title, thread_id=thread_id,
thread_type=thread_type, ts=ts, metadata=metadata, msg=m) thread_type=thread_type, ts=ts, metadata=metadata, msg=m)
# Forced fetch # Forced fetch
elif delta.get("class") == "ForcedFetch": elif delta_class == "ForcedFetch":
mid = delta.get("messageId") mid = delta.get("messageId")
if mid is None: if mid is None:
self.onUnknownMesssageType(msg=m) self.onUnknownMesssageType(msg=m)
@@ -2077,7 +2329,7 @@ class Client(object):
image_metadata = fetch_data.get("image_with_metadata") image_metadata = fetch_data.get("image_with_metadata")
image_id = int(image_metadata["legacy_attachment_id"]) if image_metadata else None image_id = int(image_metadata["legacy_attachment_id"]) if image_metadata else None
self.onImageChange(mid=mid, author_id=author_id, new_image=image_id, thread_id=thread_id, self.onImageChange(mid=mid, author_id=author_id, new_image=image_id, thread_id=thread_id,
thread_type=ThreadType.GROUP, ts=ts) thread_type=ThreadType.GROUP, ts=ts, msg=m)
# Nickname change # Nickname change
elif delta_type == "change_thread_nickname": elif delta_type == "change_thread_nickname":
@@ -2108,7 +2360,7 @@ class Client(object):
thread_id=thread_id, thread_type=thread_type, ts=ts, msg=m) thread_id=thread_id, thread_type=thread_type, ts=ts, msg=m)
# Message delivered # Message delivered
elif delta.get("class") == "DeliveryReceipt": elif delta_class == "DeliveryReceipt":
message_ids = delta["messageIds"] message_ids = delta["messageIds"]
delivered_for = str(delta.get("actorFbId") or delta["threadKey"]["otherUserFbId"]) delivered_for = str(delta.get("actorFbId") or delta["threadKey"]["otherUserFbId"])
ts = int(delta["deliveredWatermarkTimestampMs"]) ts = int(delta["deliveredWatermarkTimestampMs"])
@@ -2118,7 +2370,7 @@ class Client(object):
metadata=metadata, msg=m) metadata=metadata, msg=m)
# Message seen # Message seen
elif delta.get("class") == "ReadReceipt": elif delta_class == "ReadReceipt":
seen_by = str(delta.get("actorFbId") or delta["threadKey"]["otherUserFbId"]) seen_by = str(delta.get("actorFbId") or delta["threadKey"]["otherUserFbId"])
seen_ts = int(delta["actionTimestampMs"]) seen_ts = int(delta["actionTimestampMs"])
delivered_ts = int(delta["watermarkTimestampMs"]) delivered_ts = int(delta["watermarkTimestampMs"])
@@ -2127,7 +2379,7 @@ class Client(object):
seen_ts=seen_ts, ts=delivered_ts, metadata=metadata, msg=m) seen_ts=seen_ts, ts=delivered_ts, metadata=metadata, msg=m)
# Messages marked as seen # Messages marked as seen
elif delta.get("class") == "MarkRead": elif delta_class == "MarkRead":
seen_ts = int(delta.get("actionTimestampMs") or delta.get("actionTimestamp")) seen_ts = int(delta.get("actionTimestampMs") or delta.get("actionTimestamp"))
delivered_ts = int(delta.get("watermarkTimestampMs") or delta.get("watermarkTimestamp")) delivered_ts = int(delta.get("watermarkTimestampMs") or delta.get("watermarkTimestamp"))
@@ -2227,6 +2479,61 @@ class Client(object):
self.onPlanParticipation(mid=mid, plan=plan, take_part=take_part, author_id=author_id, self.onPlanParticipation(mid=mid, plan=plan, take_part=take_part, author_id=author_id,
thread_id=thread_id, thread_type=thread_type, ts=ts, metadata=metadata, msg=m) thread_id=thread_id, thread_type=thread_type, ts=ts, metadata=metadata, msg=m)
# Client payload (that weird numbers)
elif delta_class == "ClientPayload":
payload = json.loads("".join(chr(z) for z in delta['payload']))
ts = m.get("ofd_ts")
for d in payload.get('deltas', []):
# Message reaction
if d.get('deltaMessageReaction'):
i = d['deltaMessageReaction']
thread_id, thread_type = getThreadIdAndThreadType(i)
mid = i["messageId"]
author_id = str(i["userId"])
reaction = MessageReaction(i["reaction"]) if i.get("reaction") else None
add_reaction = not bool(i["action"])
if add_reaction:
self.onReactionAdded(mid=mid, reaction=reaction, author_id=author_id,
thread_id=thread_id, thread_type=thread_type, ts=ts, msg=m)
else:
self.onReactionRemoved(mid=mid, author_id=author_id, thread_id=thread_id,
thread_type=thread_type, ts=ts, msg=m)
# Viewer status change
elif d.get('deltaChangeViewerStatus'):
i = d['deltaChangeViewerStatus']
thread_id, thread_type = getThreadIdAndThreadType(i)
author_id = str(i["actorFbid"])
reason = i["reason"]
can_reply = i["canViewerReply"]
if reason == 2:
if can_reply:
self.onUnblock(author_id=author_id, thread_id=thread_id, thread_type=thread_type, ts=ts, msg=m)
else:
self.onBlock(author_id=author_id, thread_id=thread_id, thread_type=thread_type, ts=ts, msg=m)
# Live location info
elif d.get('liveLocationData'):
i = d['liveLocationData']
thread_id, thread_type = getThreadIdAndThreadType(i)
for l in i['messageLiveLocations']:
mid = l["messageId"]
author_id = str(l["senderId"])
location = graphql_to_live_location(l)
self.onLiveLocation(mid=mid, location=location, author_id=author_id, thread_id=thread_id,
thread_type=thread_type, ts=ts, msg=m)
# Message deletion
elif d.get('deltaRecallMessageData'):
i = d['deltaRecallMessageData']
thread_id, thread_type = getThreadIdAndThreadType(i)
mid = i['messageID']
ts = i['deletionTimestamp']
author_id = str(i['senderID'])
self.onMessageUnsent(mid=mid, author_id=author_id, thread_id=thread_id, thread_type=thread_type,
ts=ts, msg=m)
# New message # New message
elif delta.get("class") == "NewMessage": elif delta.get("class") == "NewMessage":
mentions = [] mentions = []
@@ -2238,6 +2545,7 @@ class Client(object):
sticker = None sticker = None
attachments = [] attachments = []
unsent = False
if delta.get('attachments'): if delta.get('attachments'):
try: try:
for a in delta['attachments']: for a in delta['attachments']:
@@ -2245,17 +2553,23 @@ class Client(object):
if mercury.get('blob_attachment'): if mercury.get('blob_attachment'):
image_metadata = a.get('imageMetadata', {}) image_metadata = a.get('imageMetadata', {})
attach_type = mercury['blob_attachment']['__typename'] attach_type = mercury['blob_attachment']['__typename']
attachment = graphql_to_attachment(mercury.get('blob_attachment', {})) attachment = graphql_to_attachment(mercury['blob_attachment'])
if attach_type == ['MessageFile', 'MessageVideo', 'MessageAudio']: if attach_type in ['MessageFile', 'MessageVideo', 'MessageAudio']:
# TODO: Add more data here for audio files # TODO: Add more data here for audio files
attachment.size = int(a['fileSize']) attachment.size = int(a['fileSize'])
attachments.append(attachment) attachments.append(attachment)
elif mercury.get('sticker_attachment'): elif mercury.get('sticker_attachment'):
sticker = graphql_to_sticker(a['mercury']['sticker_attachment']) sticker = graphql_to_sticker(mercury['sticker_attachment'])
elif mercury.get('extensible_attachment'): elif mercury.get('extensible_attachment'):
# TODO: Add more data here for shared stuff (URLs, events and so on) attachment = graphql_to_extensible_attachment(mercury['extensible_attachment'])
pass if isinstance(attachment, UnsentMessage):
unsent = True
elif attachment:
attachments.append(attachment)
except Exception: except Exception:
log.exception('An exception occured while reading attachments: {}'.format(delta['attachments'])) log.exception('An exception occured while reading attachments: {}'.format(delta['attachments']))
@@ -2267,12 +2581,13 @@ class Client(object):
mentions=mentions, mentions=mentions,
emoji_size=emoji_size, emoji_size=emoji_size,
sticker=sticker, sticker=sticker,
attachments=attachments attachments=attachments,
) )
message.uid = mid message.uid = mid
message.author = author_id message.author = author_id
message.timestamp = ts message.timestamp = ts
#message.reactions = {} #message.reactions = {}
message.unsent = unsent
thread_id, thread_type = getThreadIdAndThreadType(metadata) thread_id, thread_type = getThreadIdAndThreadType(metadata)
self.onMessage(mid=mid, author_id=author_id, message=delta.get('body', ''), message_object=message, self.onMessage(mid=mid, author_id=author_id, message=delta.get('body', ''), message_object=message,
thread_id=thread_id, thread_type=thread_type, ts=ts, metadata=metadata, msg=m) thread_id=thread_id, thread_type=thread_type, ts=ts, metadata=metadata, msg=m)
@@ -2322,12 +2637,48 @@ class Client(object):
# Chat timestamp # Chat timestamp
elif mtype == "chatproxy-presence": elif mtype == "chatproxy-presence":
buddylist = {} buddylist = dict()
for _id in m.get('buddyList', {}): for _id in m.get('buddyList', {}):
payload = m['buddyList'][_id] payload = m['buddyList'][_id]
buddylist[_id] = payload.get('lat')
last_active = payload.get('lat')
active = payload.get('p') in [2, 3]
in_game = int(_id) in m.get('gamers', {})
buddylist[_id] = last_active
if self._buddylist.get(_id):
self._buddylist[_id].last_active = last_active
self._buddylist[_id].active = active
self._buddylist[_id].in_game = in_game
else:
self._buddylist[_id] = ActiveStatus(active=active, last_active=last_active, in_game=in_game)
self.onChatTimestamp(buddylist=buddylist, msg=m) self.onChatTimestamp(buddylist=buddylist, msg=m)
# Buddylist overlay
elif mtype == "buddylist_overlay":
statuses = dict()
for _id in m.get('overlay', {}):
payload = m['overlay'][_id]
last_active = payload.get('la')
active = payload.get('a') in [2, 3]
in_game = self._buddylist[_id].in_game if self._buddylist.get(_id) else False
status = ActiveStatus(active=active, last_active=last_active, in_game=in_game)
if self._buddylist.get(_id):
self._buddylist[_id].last_active = last_active
self._buddylist[_id].active = active
self._buddylist[_id].in_game = in_game
else:
self._buddylist[_id] = status
statuses[_id] = status
self.onBuddylistOverlay(statuses=statuses, msg=m)
# Unknown message type # Unknown message type
else: else:
self.onUnknownMesssageType(msg=m) self.onUnknownMesssageType(msg=m)
@@ -2343,20 +2694,24 @@ class Client(object):
""" """
self.listening = True self.listening = True
def doOneListen(self, markAlive=True): def doOneListen(self, markAlive=None):
""" """
Does one cycle of the listening loop. Does one cycle of the listening loop.
This method is useful if you want to control fbchat from an external event loop This method is useful if you want to control fbchat from an external event loop
:param markAlive: Whether this should ping the Facebook server before running .. warning::
:type markAlive: bool `markAlive` parameter is deprecated now, use :func:`fbchat.Client.setActiveStatus`
or `markAlive` parameter in :func:`fbchat.Client.listen` instead.
:return: Whether the loop should keep running :return: Whether the loop should keep running
:rtype: bool :rtype: bool
""" """
if markAlive is not None:
self._markAlive = markAlive
try: try:
if markAlive: if self._markAlive:
self._ping() self._ping()
content = self._pullMessage(markAlive) content = self._pullMessage()
if content: if content:
self._parseMessage(content) self._parseMessage(content)
except KeyboardInterrupt: except KeyboardInterrupt:
@@ -2383,21 +2738,33 @@ class Client(object):
self.listening = False self.listening = False
self.sticky, self.pool = (None, None) self.sticky, self.pool = (None, None)
def listen(self, markAlive=True): def listen(self, markAlive=None):
""" """
Initializes and runs the listening loop continually Initializes and runs the listening loop continually
:param markAlive: Whether this should ping the Facebook server each time the loop runs :param markAlive: Whether this should ping the Facebook server each time the loop runs
:type markAlive: bool :type markAlive: bool
""" """
if markAlive is not None:
self.setActiveStatus(markAlive)
self.startListening() self.startListening()
self.onListening() self.onListening()
while self.listening and self.doOneListen(markAlive): while self.listening and self.doOneListen():
pass pass
self.stopListening() self.stopListening()
def setActiveStatus(self, markAlive):
"""
Changes client active status while listening
:param markAlive: Whether to show if client is active
:type markAlive: bool
"""
self._markAlive = markAlive
""" """
END LISTEN METHODS END LISTEN METHODS
""" """
@@ -2509,15 +2876,18 @@ class Client(object):
log.info("Title change from {} in {} ({}): {}".format(author_id, thread_id, thread_type.name, new_title)) log.info("Title change from {} in {} ({}): {}".format(author_id, thread_id, thread_type.name, new_title))
def onImageChange(self, mid=None, author_id=None, new_image=None, thread_id=None, thread_type=ThreadType.GROUP, ts=None): def onImageChange(self, mid=None, author_id=None, new_image=None, thread_id=None, thread_type=ThreadType.GROUP, ts=None, msg=None):
""" """
Called when the client is listening, and somebody changes the image of a thread Called when the client is listening, and somebody changes the image of a thread
:param mid: The action ID :param mid: The action ID
:param new_image: The ID of the new image
:param author_id: The ID of the person who changed the image :param author_id: The ID of the person who changed the image
:param new_image: The ID of the new image
:param thread_id: Thread ID that the action was sent to. See :ref:`intro_threads` :param thread_id: Thread ID that the action was sent to. See :ref:`intro_threads`
:param thread_type: Type of thread that the action was sent to. See :ref:`intro_threads`
:param ts: A timestamp of the action :param ts: A timestamp of the action
:param msg: A full set of the data recieved
:type thread_type: models.ThreadType
""" """
log.info("{} changed thread image in {}".format(author_id, thread_id)) log.info("{} changed thread image in {}".format(author_id, thread_id))
@@ -2628,6 +2998,19 @@ class Client(object):
""" """
log.info("Marked messages as seen in threads {} at {}s".format([(x[0], x[1].name) for x in threads], seen_ts/1000)) log.info("Marked messages as seen in threads {} at {}s".format([(x[0], x[1].name) for x in threads], seen_ts/1000))
def onMessageUnsent(self, mid=None, author_id=None, thread_id=None, thread_type=None, ts=None, msg=None):
"""
Called when the client is listening, and someone unsends (deletes for everyone) a message
:param mid: ID of the unsent message
:param author_id: The ID of the person who unsent the message
:param thread_id: Thread ID that the action was sent to. See :ref:`intro_threads`
:param thread_type: Type of thread that the action was sent to. See :ref:`intro_threads`
:param ts: A timestamp of the action
:param msg: A full set of the data recieved
:type thread_type: models.ThreadType
"""
log.info("{} unsent the message {} in {} ({}) at {}s".format(author_id, repr(mid), thread_id, thread_type.name, ts/1000))
def onPeopleAdded(self, mid=None, added_ids=None, author_id=None, thread_id=None, ts=None, msg=None): def onPeopleAdded(self, mid=None, added_ids=None, author_id=None, thread_id=None, ts=None, msg=None):
""" """
@@ -2709,40 +3092,78 @@ class Client(object):
""" """
log.info("{} played \"{}\" in {} ({})".format(author_id, game_name, thread_id, thread_type.name)) log.info("{} played \"{}\" in {} ({})".format(author_id, game_name, thread_id, thread_type.name))
def onQprimer(self, ts=None, msg=None): def onReactionAdded(self, mid=None, reaction=None, author_id=None, thread_id=None, thread_type=None, ts=None, msg=None):
""" """
Called when the client just started listening Called when the client is listening, and somebody reacts to a message
:param mid: Message ID, that user reacted to
:param reaction: Reaction
:param add_reaction: Whether user added or removed reaction
:param author_id: The ID of the person who reacted to the message
:param thread_id: Thread ID that the action was sent to. See :ref:`intro_threads`
:param thread_type: Type of thread that the action was sent to. See :ref:`intro_threads`
:param ts: A timestamp of the action :param ts: A timestamp of the action
:param msg: A full set of the data recieved :param msg: A full set of the data recieved
:type reaction: models.MessageReaction
:type thread_type: models.ThreadType
""" """
pass log.info("{} reacted to message {} with {} in {} ({})".format(author_id, mid, reaction.name, thread_id, thread_type.name))
def onChatTimestamp(self, buddylist=None, msg=None): def onReactionRemoved(self, mid=None, author_id=None, thread_id=None, thread_type=None, ts=None, msg=None):
""" """
Called when the client receives chat online presence update Called when the client is listening, and somebody removes reaction from a message
:param buddylist: A list of dicts with friend id and last seen timestamp :param mid: Message ID, that user reacted to
:param author_id: The ID of the person who removed reaction
:param thread_id: Thread ID that the action was sent to. See :ref:`intro_threads`
:param thread_type: Type of thread that the action was sent to. See :ref:`intro_threads`
:param ts: A timestamp of the action
:param msg: A full set of the data recieved :param msg: A full set of the data recieved
:type thread_type: models.ThreadType
""" """
log.debug('Chat Timestamps received: {}'.format(buddylist)) log.info("{} removed reaction from {} message in {} ({})".format(author_id, mid, thread_id, thread_type))
def onUnknownMesssageType(self, msg=None): def onBlock(self, author_id=None, thread_id=None, thread_type=None, ts=None, msg=None):
""" """
Called when the client is listening, and some unknown data was recieved Called when the client is listening, and somebody blocks client
:param author_id: The ID of the person who blocked
:param thread_id: Thread ID that the action was sent to. See :ref:`intro_threads`
:param thread_type: Type of thread that the action was sent to. See :ref:`intro_threads`
:param ts: A timestamp of the action
:param msg: A full set of the data recieved :param msg: A full set of the data recieved
:type thread_type: models.ThreadType
""" """
log.debug('Unknown message received: {}'.format(msg)) log.info("{} blocked {} ({}) thread".format(author_id, thread_id, thread_type.name))
def onMessageError(self, exception=None, msg=None): def onUnblock(self, author_id=None, thread_id=None, thread_type=None, ts=None, msg=None):
""" """
Called when an error was encountered while parsing recieved data Called when the client is listening, and somebody blocks client
:param exception: The exception that was encountered :param author_id: The ID of the person who unblocked
:param thread_id: Thread ID that the action was sent to. See :ref:`intro_threads`
:param thread_type: Type of thread that the action was sent to. See :ref:`intro_threads`
:param ts: A timestamp of the action
:param msg: A full set of the data recieved :param msg: A full set of the data recieved
:type thread_type: models.ThreadType
""" """
log.exception('Exception in parsing of {}'.format(msg)) log.info("{} unblocked {} ({}) thread".format(author_id, thread_id, thread_type.name))
def onLiveLocation(self, mid=None, location=None, author_id=None, thread_id=None, thread_type=None, ts=None, msg=None):
"""
Called when the client is listening and somebody sends live location info
:param mid: The action ID
:param location: Sent location info
:param author_id: The ID of the person who sent location info
:param thread_id: Thread ID that the action was sent to. See :ref:`intro_threads`
:param thread_type: Type of thread that the action was sent to. See :ref:`intro_threads`
:param ts: A timestamp of the action
:param msg: A full set of the data recieved
:type location: models.LiveLocationAttachment
:type thread_type: models.ThreadType
"""
log.info("{} sent live location info in {} ({}) with latitude {} and longitude {}".format(author_id, thread_id, thread_type, location.latitude, location.longitude))
def onCallStarted(self, mid=None, caller_id=None, is_video_call=None, thread_id=None, thread_type=None, ts=None, metadata=None, msg=None): def onCallStarted(self, mid=None, caller_id=None, is_video_call=None, thread_id=None, thread_type=None, ts=None, metadata=None, msg=None):
""" """
@@ -2914,6 +3335,7 @@ class Client(object):
:param metadata: Extra metadata about the action :param metadata: Extra metadata about the action
:param msg: A full set of the data recieved :param msg: A full set of the data recieved
:type plan: models.Plan :type plan: models.Plan
:type take_part: bool
:type thread_type: models.ThreadType :type thread_type: models.ThreadType
""" """
if take_part: if take_part:
@@ -2921,6 +3343,51 @@ class Client(object):
else: else:
log.info("{} won't take part in {} in {} ({})".format(author_id, plan, thread_id, thread_type.name)) log.info("{} won't take part in {} in {} ({})".format(author_id, plan, thread_id, thread_type.name))
def onQprimer(self, ts=None, msg=None):
"""
Called when the client just started listening
:param ts: A timestamp of the action
:param msg: A full set of the data recieved
"""
pass
def onChatTimestamp(self, buddylist=None, msg=None):
"""
Called when the client receives chat online presence update
:param buddylist: A list of dicts with friend id and last seen timestamp
:param msg: A full set of the data recieved
"""
log.debug('Chat Timestamps received: {}'.format(buddylist))
def onBuddylistOverlay(self, statuses=None, msg=None):
"""
Called when the client is listening and client receives information about friend active status
:param statuses: Dictionary with user IDs as keys and :class:`models.ActiveStatus` as values
:param msg: A full set of the data recieved
:type statuses: dict
"""
log.debug('Buddylist overlay received: {}'.format(statuses))
def onUnknownMesssageType(self, msg=None):
"""
Called when the client is listening, and some unknown data was recieved
:param msg: A full set of the data recieved
"""
log.debug('Unknown message received: {}'.format(msg))
def onMessageError(self, exception=None, msg=None):
"""
Called when an error was encountered while parsing recieved data
:param exception: The exception that was encountered
:param msg: A full set of the data recieved
"""
log.exception('Exception in parsing of {}'.format(msg))
""" """
END EVENTS END EVENTS
""" """

View File

@@ -26,12 +26,11 @@ class ConcatJSONDecoder(json.JSONDecoder):
def graphql_color_to_enum(color): def graphql_color_to_enum(color):
if color is None: if color is None:
return None return None
if len(color) == 0: if not color:
return ThreadColor.MESSENGER_BLUE return ThreadColor.MESSENGER_BLUE
try: color = color[2:] # Strip the alpha value
return ThreadColor('#{}'.format(color[2:].lower())) color_value = '#{}'.format(color.lower())
except ValueError: return enum_extend_if_invalid(ThreadColor, color_value)
raise FBchatException('Could not get ThreadColor from color: {}'.format(color))
def get_customization_info(thread): def get_customization_info(thread):
if thread is None or thread.get('customization_info') is None: if thread is None or thread.get('customization_info') is None:
@@ -128,9 +127,84 @@ def graphql_to_attachment(a):
uid=a.get('legacy_attachment_id') 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'),
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): def graphql_to_poll(a):
rtn = Poll( rtn = Poll(
title=a.get('title') if a.get('title') else a.get("text"), title=a.get('title') if a.get('title') else a.get('text'),
options=[graphql_to_poll_option(m) for m in a.get('options')] options=[graphql_to_poll_option(m) for m in a.get('options')]
) )
rtn.uid = int(a["id"]) rtn.uid = int(a["id"])
@@ -193,6 +267,24 @@ def graphql_to_plan(a):
rtn.invited = [m.get('node').get('id') for m in guests if m.get('guest_list_state') == "INVITED"] rtn.invited = [m.get('node').get('id') for m in guests if m.get('guest_list_state') == "INVITED"]
return rtn 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): def graphql_to_message(message):
if message.get('message_sender') is None: if message.get('message_sender') is None:
message['message_sender'] = {} message['message_sender'] = {}
@@ -207,13 +299,27 @@ def graphql_to_message(message):
rtn.uid = str(message.get('message_id')) rtn.uid = str(message.get('message_id'))
rtn.author = str(message.get('message_sender').get('id')) rtn.author = str(message.get('message_sender').get('id'))
rtn.timestamp = message.get('timestamp_precise') rtn.timestamp = message.get('timestamp_precise')
rtn.unsent = False
if message.get('unread') is not None: if message.get('unread') is not None:
rtn.is_read = not message['unread'] rtn.is_read = not message['unread']
rtn.reactions = {str(r['user']['id']):MessageReaction(r['reaction']) for r in message.get('message_reactions')} 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: if message.get('blob_attachments') is not None:
rtn.attachments = [graphql_to_attachment(attachment) for attachment in message['blob_attachments']] rtn.attachments = [graphql_to_attachment(attachment) for attachment in message['blob_attachments']]
# TODO: This is still missing parsing: if message.get('platform_xmd_encoded'):
# message.get('extensible_attachment') 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 return rtn
def graphql_to_user(user): def graphql_to_user(user):
@@ -477,7 +583,7 @@ class GraphQL(object):
""" """
SEARCH_USER = """ SEARCH_USER = """
Query SearchUser(<search> = '', <limit> = 1) { Query SearchUser(<search> = '', <limit> = 10) {
entities_named(<search>) { entities_named(<search>) {
search_results.of_type(user).first(<limit>) as users { search_results.of_type(user).first(<limit>) as users {
nodes { nodes {
@@ -489,7 +595,7 @@ class GraphQL(object):
""" + FRAGMENT_USER """ + FRAGMENT_USER
SEARCH_GROUP = """ SEARCH_GROUP = """
Query SearchGroup(<search> = '', <limit> = 1, <pic_size> = 32) { Query SearchGroup(<search> = '', <limit> = 10, <pic_size> = 32) {
viewer() { viewer() {
message_threads.with_thread_name(<search>).last(<limit>) as groups { message_threads.with_thread_name(<search>).last(<limit>) as groups {
nodes { nodes {
@@ -501,7 +607,7 @@ class GraphQL(object):
""" + FRAGMENT_GROUP """ + FRAGMENT_GROUP
SEARCH_PAGE = """ SEARCH_PAGE = """
Query SearchPage(<search> = '', <limit> = 1) { Query SearchPage(<search> = '', <limit> = 10) {
entities_named(<search>) { entities_named(<search>) {
search_results.of_type(page).first(<limit>) as pages { search_results.of_type(page).first(<limit>) as pages {
nodes { nodes {
@@ -513,7 +619,7 @@ class GraphQL(object):
""" + FRAGMENT_PAGE """ + FRAGMENT_PAGE
SEARCH_THREAD = """ SEARCH_THREAD = """
Query SearchThread(<search> = '', <limit> = 1) { Query SearchThread(<search> = '', <limit> = 10) {
entities_named(<search>) { entities_named(<search>) {
search_results.first(<limit>) as threads { search_results.first(<limit>) as threads {
nodes { nodes {

View File

@@ -1,7 +1,8 @@
# -*- coding: UTF-8 -*- # -*- coding: UTF-8 -*-
from __future__ import unicode_literals from __future__ import unicode_literals
import enum import aenum
from string import Formatter
class FBchatException(Exception): class FBchatException(Exception):
@@ -190,8 +191,12 @@ class Message(object):
sticker = None sticker = None
#: A list of attachments #: A list of attachments
attachments = None 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): def __init__(self, text=None, mentions=None, emoji_size=None, sticker=None, attachments=None, quick_replies=None):
"""Represents a Facebook message""" """Represents a Facebook message"""
self.text = text self.text = text
if mentions is None: if mentions is None:
@@ -202,8 +207,12 @@ class Message(object):
if attachments is None: if attachments is None:
attachments = [] attachments = []
self.attachments = attachments self.attachments = attachments
if quick_replies is None:
quick_replies = []
self.quick_replies = quick_replies
self.reactions = {} self.reactions = {}
self.read_by = [] self.read_by = []
self.deleted = False
def __repr__(self): def __repr__(self):
return self.__unicode__() return self.__unicode__()
@@ -211,6 +220,52 @@ class Message(object):
def __unicode__(self): def __unicode__(self):
return '<Message ({}): {}, mentions={} emoji_size={} attachments={}>'.format(self.uid, repr(self.text), self.mentions, self.emoji_size, self.attachments) 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): class Attachment(object):
#: The attachment ID #: The attachment ID
uid = None uid = None
@@ -219,6 +274,12 @@ class Attachment(object):
"""Represents a Facebook attachment""" """Represents a Facebook attachment"""
self.uid = uid 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): class Sticker(Attachment):
#: The sticker-pack's ID #: The sticker-pack's ID
pack = None pack = None
@@ -251,9 +312,79 @@ class Sticker(Attachment):
super(Sticker, self).__init__(*args, **kwargs) super(Sticker, self).__init__(*args, **kwargs)
class ShareAttachment(Attachment): class ShareAttachment(Attachment):
def __init__(self, **kwargs): #: ID of the author of the shared post
"""Represents a shared item (eg. URL) that has been sent as a Facebook attachment - *Currently Incomplete!*""" 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) 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): class FileAttachment(Attachment):
#: Url where you can download the file #: Url where you can download the file
@@ -442,6 +573,73 @@ class Mention(object):
def __unicode__(self): def __unicode__(self):
return '<Mention {}: offset={} length={}>'.format(self.thread_id, self.offset, self.length) 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): class Poll(object):
#: ID of the poll #: ID of the poll
uid = None uid = None
@@ -523,7 +721,26 @@ class Plan(object):
def __unicode__(self): def __unicode__(self):
return '<Plan ({}): {} time={}, location={}, location_id={}>'.format(self.uid, repr(self.title), self.time, repr(self.location), repr(self.location_id)) return '<Plan ({}): {} time={}, location={}, location_id={}>'.format(self.uid, repr(self.title), self.time, repr(self.location), repr(self.location_id))
class Enum(enum.Enum): 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""" """Used internally by fbchat to support enumerations"""
def __repr__(self): def __repr__(self):
# For documentation: # For documentation:

View File

@@ -11,13 +11,15 @@ from os.path import basename
import warnings import warnings
import logging import logging
import requests import requests
import aenum
from .models import * from .models import *
try: try:
from urllib.parse import urlencode from urllib.parse import urlencode, parse_qs, urlparse
basestring = (str, bytes) basestring = (str, bytes)
except ImportError: except ImportError:
from urllib import urlencode from urllib import urlencode
from urlparse import parse_qs, urlparse
basestring = basestring basestring = basestring
# Python 2's `input` executes the input, whereas `raw_input` just returns the input # Python 2's `input` executes the input, whereas `raw_input` just returns the input
@@ -140,6 +142,7 @@ class ReqUrl(object):
GET_POLL_OPTIONS = "https://www.facebook.com/ajax/mercury/get_poll_options" 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" 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" MARK_SPAM = "https://www.facebook.com/ajax/mercury/mark_spam.php?dpr=1"
UNSEND = "https://www.facebook.com/messaging/unsend_message/?dpr=1"
pull_channel = 0 pull_channel = 0
@@ -297,3 +300,18 @@ def get_files_from_paths(filenames):
yield files yield files
for fn, fp, ft in files: for fn, fp, ft in files:
fp.close() 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]

52
pyproject.toml Normal file
View File

@@ -0,0 +1,52 @@
[build-system]
requires = ["flit"]
build-backend = "flit.buildapi"
[tool.flit.metadata]
module = "fbchat"
author = "Taehoon Kim"
author-email = "carpedm20@gmail.com"
maintainer = "Mads Marquart"
maintainer-email = "madsmtm@gmail.com"
home-page = "https://github.com/carpedm20/fbchat/"
requires = [
"aenum",
"requests",
"beautifulsoup4",
]
description-file = "README.rst"
classifiers = [
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"Intended Audience :: Information Technology",
"License :: OSI Approved :: BSD License",
"Operating System :: OS Independent",
"Natural Language :: English",
"Programming Language :: Python",
"Programming Language :: Python :: 2.7",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.4",
"Programming Language :: Python :: 3.5",
"Programming Language :: Python :: 3.6",
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: Implementation :: CPython",
"Programming Language :: Python :: Implementation :: PyPy",
"Topic :: Communications :: Chat",
"Topic :: Internet :: WWW/HTTP",
"Topic :: Internet :: WWW/HTTP :: Dynamic Content",
"Topic :: Software Development :: Libraries",
"Topic :: Software Development :: Libraries :: Python Modules",
]
requires-python = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, <4.0"
keywords = "Facebook FB Messenger Library Chat Api Bot"
license = "BSD 3-Clause"
[tool.flit.metadata.urls]
Documentation = "https://fbchat.readthedocs.io/"
Repository = "https://github.com/carpedm20/fbchat/"
[tool.flit.metadata.requires-extra]
test = [
"pytest~=4.0",
"six",
]

View File

@@ -1,3 +0,0 @@
requests
beautifulsoup4
enum34; python_version < '3.4'

View File

@@ -1,5 +0,0 @@
#!/bin/bash
set -ex
python -m pytest -m offline --color=yes

View File

@@ -1,18 +0,0 @@
#!/bin/bash
set -ex
if ! python -m pytest --color=yes; then
echo << EOF
-----------------------------------------------------------------
-----------------------------------------------------------------
-----------------------------------------------------------------
Some tests failed! Rerunning them, since they can be kinda flaky.
-----------------------------------------------------------------
-----------------------------------------------------------------
-----------------------------------------------------------------
EOF
python -m pytest --last-failed --color=yes
fi

View File

@@ -1,51 +0,0 @@
[metadata]
name = fbchat
version = attr: fbchat.__version__
license = BSD 3-Clause
license_file = LICENSE
author = Taehoon Kim
author_email = carpedm20@gmail.com
maintainer = Mads Marquart
maintainer_email = madsmtm@gmail.com
description = Facebook Chat (Messenger) for Python
long_description = file: README.rst
long_description_content_type = text/x-rst
keywords = Facebook FB Messenger Chat Api Bot
classifiers =
Development Status :: 3 - Alpha
Intended Audience :: Developers
Intended Audience :: Information Technology
License :: OSI Approved :: BSD License
Operating System :: OS Independent
Natural Language :: English
Programming Language :: Python
Programming Language :: Python :: 2.7
Programming Language :: Python :: 3.4
Programming Language :: Python :: 3.5
Programming Language :: Python :: 3.6
Programming Language :: Python :: Implementation :: CPython
Programming Language :: Python :: Implementation :: PyPy
Topic :: Communications :: Chat
Topic :: Internet :: WWW/HTTP :: Dynamic Content
Topic :: Software Development :: Libraries :: Python Modules
url = https://github.com/carpedm20/fbchat/
project_urls =
Documentation = https://fbchat.readthedocs.io/
Repository = https://github.com/carpedm20/fbchat/
[options]
zip_safe = True
include_package_data = True
packages = find:
python_requires = >=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, <4.0
install_requires =
requests
beautifulsoup4
# May not work in pip with bdist_wheel
# See https://wheel.readthedocs.io/en/latest/#defining-conditional-dependencies
# It is therefore defined in setup.py
# enum34; python_version < '3.4'

View File

@@ -1,8 +0,0 @@
#!/usr/bin/env python
# -*- coding: UTF-8 -*-
from __future__ import unicode_literals
from setuptools import setup
setup(extras_require={':python_version < "3.4"': ['enum34']})

View File

@@ -19,6 +19,11 @@ def test_fetch_thread_list(client1):
assert len(threads) == 2 assert len(threads) == 2
def test_fetch_threads(client1):
threads = client1.fetchThreads(limit=2)
assert len(threads) == 2
@pytest.mark.parametrize("emoji, emoji_size", EMOJI_LIST) @pytest.mark.parametrize("emoji, emoji_size", EMOJI_LIST)
def test_fetch_message_emoji(client, emoji, emoji_size): def test_fetch_message_emoji(client, emoji, emoji_size):
mid = client.sendEmoji(emoji, emoji_size) mid = client.sendEmoji(emoji, emoji_size)