Compare commits
89 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
e6bc5bbab3 | ||
|
de5f3a9d9e | ||
|
7f0da012c2 | ||
|
76ecbf5eb0 | ||
|
06881a4c70 | ||
|
c14fdd82db | ||
|
b1a02ad930 | ||
|
2b580c60e9 | ||
|
27ffba3b14 | ||
|
fb7bf437ba | ||
|
d8baf0b9e7 | ||
|
a6945fe880 | ||
|
6ff77dd8c7 | ||
|
1d925a608b | ||
|
646669ca75 | ||
|
0ec2baaa83 | ||
|
5abaaefd1c | ||
|
687afea0f2 | ||
|
7398d4fa2b | ||
|
d73c8c3627 | ||
|
f921b91c5b | ||
|
8ed3c1b159 | ||
|
4f947cdbb5 | ||
|
ec6c29052a | ||
|
6b117502f3 | ||
|
a367aa0b31 | ||
|
7f6843df55 | ||
|
4b485d54b6 | ||
|
e80a040db4 | ||
|
c357fd085b | ||
|
d0c5f29b0a | ||
|
3e7b20c379 | ||
|
f4a997c0ef | ||
|
102e74bb63 | ||
|
84fa15e44c | ||
|
7b8ecf8fe3 | ||
|
79ebf920ea | ||
|
0d05d42f70 | ||
|
95989b6da7 | ||
|
22e57f99a1 | ||
|
b9d29c0417 | ||
|
edc33db9e8 | ||
|
45d8b45d96 | ||
|
b6a6d7dc68 | ||
|
c57b84cd0b | ||
|
78e7841b5e | ||
|
e41d981449 | ||
|
381227af66 | ||
|
2f8d0728ba | ||
|
13bfc5f2f9 | ||
|
d4446280c7 | ||
|
861f17bc4d | ||
|
41bbe18e3d | ||
|
5f9c357a15 | ||
|
c089298f46 | ||
|
be968e0caa | ||
|
d32b7b612a | ||
|
8739318101 | ||
|
1ac569badd | ||
|
e38f891693 | ||
|
b7ea8e6001 | ||
|
b0bf5ba8e0 | ||
|
8169a5f776 | ||
|
492465a525 | ||
|
f185e44f93 | ||
|
5f2c318baf | ||
|
531a5b77d0 | ||
|
f9245cdfed | ||
|
47ea88e025 | ||
|
345a473ee0 | ||
|
c6dc432d06 | ||
|
af3bd55535 | ||
|
5fa1d86191 | ||
|
d4859b675a | ||
|
9aa427031e | ||
|
9e8fe7bc1e | ||
|
90813c959d | ||
|
940a65954c | ||
|
9b4e753a79 | ||
|
e0be9029e4 | ||
|
aa3faca246 | ||
|
2edb95dfdd | ||
|
e0bb9960fb | ||
|
71608845c0 | ||
|
0048e82151 | ||
|
0767ef4902 | ||
|
abe3357e67 | ||
|
19457efe9b | ||
|
487a2eb3e3 |
118
.travis.yml
118
.travis.yml
@@ -1,90 +1,50 @@
|
||||
sudo: false
|
||||
language: python
|
||||
conditions: v1
|
||||
python: 3.6
|
||||
|
||||
# There are two accounts made specifically for Travis, and the passwords are really only encrypted for obscurity
|
||||
# The global env variables `client1_email`, `client1_password`, `client2_email`, `client2_password` and `group_id`
|
||||
# are set on the Travis Settings page
|
||||
cache: pip
|
||||
|
||||
# The tests are run with `Limit concurrent jobs = 1`, since the tests can't use the clients simultaneously
|
||||
|
||||
install:
|
||||
- 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
|
||||
before_install: pip install flit
|
||||
install: flit install --deps production --extras test
|
||||
script: pytest -m offline
|
||||
|
||||
jobs:
|
||||
include:
|
||||
# The tests are split into online and offline versions.
|
||||
# The online tests are only run against the master branch.
|
||||
# Because:
|
||||
# Travis caching is per-branch and per-job, so even though we cache the Facebook sessions via. `.pytest_cache`
|
||||
# and in `tests.utils.load_client`, we need 6 new sessions per branch. This is usually the point where Facebook
|
||||
# starts complaining, and we have to manually fix it
|
||||
- python: 2.7
|
||||
before_install:
|
||||
- sudo apt-get -y install python3-pip python3-setuptools
|
||||
- sudo pip3 install flit
|
||||
install: flit install --python python --deps production --extras test
|
||||
- python: 3.4
|
||||
- python: 3.5
|
||||
- python: 3.6
|
||||
- python: 3.7
|
||||
dist: xenial
|
||||
sudo: required
|
||||
- python: pypy3.5
|
||||
|
||||
- &test-online
|
||||
if: (branch = master OR tag IS present) AND type != pull_request
|
||||
stage: online tests
|
||||
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
|
||||
- stage: deploy
|
||||
name: Github Releases
|
||||
if: tag IS present
|
||||
install: skip
|
||||
script: flit build
|
||||
deploy:
|
||||
provider: pypi
|
||||
user: madsmtm
|
||||
password:
|
||||
secure: "VA0MLSrwIW/T2KjMwjLZCzrLHw8pJT6tAvb48t7qpBdm8x192hax61pz1TaBZoJvlzyBPFKvluftuclTc7yEFwzXe7Gjqgd/ODKZl/wXDr36hQ7BBOLPZujdwmWLvTzMh3eJZlvkgcLCzrvK3j2oW8cM/+FZeVi/5/FhVuJ4ofs="
|
||||
distributions: sdist bdist_wheel
|
||||
skip_existing: true
|
||||
provider: releases
|
||||
api_key: $GITHUB_OAUTH_TOKEN
|
||||
file_glob: true
|
||||
file: dist/*
|
||||
skip_cleanup: true
|
||||
draft: true
|
||||
on:
|
||||
tags: true
|
||||
|
||||
# We need the bdist_wheels from both Python 2 and 3
|
||||
python: 3.6
|
||||
- <<: *deploy
|
||||
python: 2.7
|
||||
- stage: deploy
|
||||
name: PyPI
|
||||
if: tag IS present
|
||||
install: skip
|
||||
script: skip
|
||||
deploy:
|
||||
provider: script
|
||||
script: flit publish
|
||||
on:
|
||||
tags: true
|
||||
|
@@ -1,3 +0,0 @@
|
||||
include LICENSE
|
||||
include CONTRIBUTING.rst
|
||||
include README.rst
|
13
README.rst
13
README.rst
@@ -5,9 +5,9 @@ fbchat: Facebook Chat (Messenger) for Python
|
||||
:target: https://github.com/carpedm20/fbchat/tree/master/LICENSE
|
||||
: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
|
||||
: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
|
||||
:target: https://fbchat.readthedocs.io
|
||||
@@ -27,17 +27,18 @@ or jump right into the code by viewing the `examples <https://github.com/carpedm
|
||||
|
||||
Installation:
|
||||
|
||||
.. code-block:: console
|
||||
.. code-block::
|
||||
|
||||
$ 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
|
||||
$ cd fbchat
|
||||
$ python setup.py install
|
||||
$ flit install
|
||||
|
||||
|
||||
Maintainer
|
||||
|
@@ -1,2 +0,0 @@
|
||||
pytest
|
||||
six
|
@@ -1,13 +1,8 @@
|
||||
# -*- coding: UTF-8 -*-
|
||||
"""Facebook Chat (Messenger) for Python
|
||||
|
||||
"""
|
||||
fbchat
|
||||
~~~~~~
|
||||
|
||||
Facebook Chat (Messenger) for Python
|
||||
|
||||
:copyright: (c) 2015 - 2018 by Taehoon Kim
|
||||
:license: BSD 3-Clause, see LICENSE for more details.
|
||||
:copyright: (c) 2015 - 2019 by Taehoon Kim
|
||||
:license: BSD 3-Clause, see LICENSE for more details.
|
||||
"""
|
||||
|
||||
from __future__ import unicode_literals
|
||||
@@ -15,10 +10,10 @@ from __future__ import unicode_literals
|
||||
from .client import *
|
||||
|
||||
__title__ = 'fbchat'
|
||||
__version__ = '1.4.2'
|
||||
__version__ = '1.6.0'
|
||||
__description__ = 'Facebook Chat (Messenger) for Python'
|
||||
|
||||
__copyright__ = 'Copyright 2015 - 2018 by Taehoon Kim'
|
||||
__copyright__ = 'Copyright 2015 - 2019 by Taehoon Kim'
|
||||
__license__ = 'BSD 3-Clause'
|
||||
|
||||
__author__ = 'Taehoon Kim; Moreels Pieter-Jan; Mads Marquart'
|
||||
|
587
fbchat/client.py
587
fbchat/client.py
@@ -62,6 +62,8 @@ class Client(object):
|
||||
self.default_thread_id = None
|
||||
self.default_thread_type = None
|
||||
self.req_url = ReqUrl()
|
||||
self._markAlive = True
|
||||
self._buddylist = dict()
|
||||
|
||||
if not user_agent:
|
||||
user_agent = choice(USER_AGENTS)
|
||||
@@ -474,6 +476,82 @@ class Client(object):
|
||||
}))
|
||||
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):
|
||||
"""
|
||||
Gets all users the client is currently chatting with
|
||||
@@ -502,7 +580,7 @@ class Client(object):
|
||||
|
||||
return users
|
||||
|
||||
def searchForUsers(self, name, limit=1):
|
||||
def searchForUsers(self, name, limit=10):
|
||||
"""
|
||||
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']]
|
||||
|
||||
def searchForPages(self, name, limit=1):
|
||||
def searchForPages(self, name, limit=10):
|
||||
"""
|
||||
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']]
|
||||
|
||||
def searchForGroups(self, name, limit=1):
|
||||
def searchForGroups(self, name, limit=10):
|
||||
"""
|
||||
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']]
|
||||
|
||||
def searchForThreads(self, name, limit=1):
|
||||
def searchForThreads(self, name, limit=10):
|
||||
"""
|
||||
Find and get a thread by its name
|
||||
|
||||
@@ -927,14 +1005,14 @@ class Client(object):
|
||||
:type image_id: str
|
||||
:return: An url where you can download the original image
|
||||
:rtype: str
|
||||
:raises: FBChatException if request failed
|
||||
:raises: FBchatException if request failed
|
||||
"""
|
||||
image_id = str(image_id)
|
||||
j = check_request(self._get(ReqUrl.ATTACHMENT_PHOTO, query={'photo_id': str(image_id)}))
|
||||
|
||||
url = get_jsmods_require(j, 3)
|
||||
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
|
||||
|
||||
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`
|
||||
:return: :class:`models.Message` object
|
||||
:rtype: models.Message
|
||||
:raises: FBChatException if request failed
|
||||
:raises: FBchatException if request failed
|
||||
"""
|
||||
thread_id, thread_type = self._getThread(thread_id, None)
|
||||
message_info = self._forcedFetch(thread_id, mid).get("message")
|
||||
@@ -958,7 +1036,7 @@ class Client(object):
|
||||
|
||||
:param poll_id: Poll ID to fetch from
|
||||
:rtype: list
|
||||
:raises: FBChatException if request failed
|
||||
:raises: FBchatException if request failed
|
||||
"""
|
||||
data = {
|
||||
"question_id": poll_id
|
||||
@@ -975,7 +1053,7 @@ class Client(object):
|
||||
:param plan_id: Plan ID to fetch from
|
||||
:return: :class:`models.Plan` object
|
||||
:rtype: models.Plan
|
||||
:raises: FBChatException if request failed
|
||||
:raises: FBchatException if request failed
|
||||
"""
|
||||
data = {
|
||||
"event_reminder_id": plan_id
|
||||
@@ -984,6 +1062,44 @@ class Client(object):
|
||||
plan = graphql_to_plan(j["payload"])
|
||||
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
|
||||
"""
|
||||
@@ -1040,6 +1156,25 @@ class Client(object):
|
||||
if message.sticker:
|
||||
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
|
||||
|
||||
def _doSendRequest(self, data, get_thread_id=False):
|
||||
@@ -1111,7 +1246,86 @@ class Client(object):
|
||||
data['specific_to_list[0]'] = "fbid:{}".format(thread_id)
|
||||
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
|
||||
|
||||
@@ -1121,7 +1335,12 @@ class Client(object):
|
||||
Returns a list of tuples with a file's ID and mimetype
|
||||
"""
|
||||
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):
|
||||
raise FBchatException("Some files could not be uploaded: {}, {}".format(j, files))
|
||||
@@ -1165,7 +1384,7 @@ class Client(object):
|
||||
"""
|
||||
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 thread_id: User/Group ID to send to. See :ref:`intro_threads`
|
||||
:param thread_type: See :ref:`intro_threads`
|
||||
@@ -1178,6 +1397,39 @@ class Client(object):
|
||||
files = self._upload(x)
|
||||
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):
|
||||
"""
|
||||
Deprecated. Use :func:`fbchat.Client._sendFiles` instead
|
||||
@@ -1482,27 +1734,26 @@ class Client(object):
|
||||
|
||||
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 reaction: Reaction emoji to use
|
||||
:type reaction: models.MessageReaction
|
||||
:param reaction: Reaction emoji to use, if None removes reaction
|
||||
:type reaction: models.MessageReaction or None
|
||||
:raises: FBchatException if request failed
|
||||
"""
|
||||
full_data = {
|
||||
data = {
|
||||
"doc_id": 1491398900900362,
|
||||
"variables": json.dumps({
|
||||
"data": {
|
||||
"action": "ADD_REACTION",
|
||||
"action": "ADD_REACTION" if reaction else "REMOVE_REACTION",
|
||||
"client_mutation_id": "1",
|
||||
"actor_id": self.uid,
|
||||
"message_id": str(message_id),
|
||||
"reaction": reaction.value
|
||||
"reaction": reaction.value if reaction else None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
j = self._post(self.req_url.MESSAGE_REACTION, full_data, fix_request=True, as_json=True)
|
||||
self._post(self.req_url.MESSAGE_REACTION, data, fix_request=True, as_json=True)
|
||||
|
||||
def createPlan(self, plan, thread_id=None):
|
||||
"""
|
||||
@@ -1742,7 +1993,7 @@ class Client(object):
|
||||
.. todo::
|
||||
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
|
||||
|
||||
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)
|
||||
|
||||
def _pullMessage(self, markAlive=True):
|
||||
def _pullMessage(self):
|
||||
"""Call pull api with seq value to get message data."""
|
||||
|
||||
data = {
|
||||
@@ -1978,7 +2229,7 @@ class Client(object):
|
||||
"sticky_token": self.sticky,
|
||||
"sticky_pool": self.pool,
|
||||
"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)
|
||||
@@ -2019,6 +2270,7 @@ class Client(object):
|
||||
|
||||
delta = m["delta"]
|
||||
delta_type = delta.get("type")
|
||||
delta_class = delta.get("class")
|
||||
metadata = delta.get("messageMetadata")
|
||||
|
||||
if metadata:
|
||||
@@ -2055,14 +2307,14 @@ class Client(object):
|
||||
thread_type=thread_type, ts=ts, metadata=metadata, msg=m)
|
||||
|
||||
# Thread title change
|
||||
elif delta.get("class") == "ThreadName":
|
||||
elif delta_class == "ThreadName":
|
||||
new_title = delta["name"]
|
||||
thread_id, thread_type = getThreadIdAndThreadType(metadata)
|
||||
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)
|
||||
|
||||
# Forced fetch
|
||||
elif delta.get("class") == "ForcedFetch":
|
||||
elif delta_class == "ForcedFetch":
|
||||
mid = delta.get("messageId")
|
||||
if mid is None:
|
||||
self.onUnknownMesssageType(msg=m)
|
||||
@@ -2077,7 +2329,7 @@ class Client(object):
|
||||
image_metadata = fetch_data.get("image_with_metadata")
|
||||
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,
|
||||
thread_type=ThreadType.GROUP, ts=ts)
|
||||
thread_type=ThreadType.GROUP, ts=ts, msg=m)
|
||||
|
||||
# Nickname change
|
||||
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)
|
||||
|
||||
# Message delivered
|
||||
elif delta.get("class") == "DeliveryReceipt":
|
||||
elif delta_class == "DeliveryReceipt":
|
||||
message_ids = delta["messageIds"]
|
||||
delivered_for = str(delta.get("actorFbId") or delta["threadKey"]["otherUserFbId"])
|
||||
ts = int(delta["deliveredWatermarkTimestampMs"])
|
||||
@@ -2118,7 +2370,7 @@ class Client(object):
|
||||
metadata=metadata, msg=m)
|
||||
|
||||
# Message seen
|
||||
elif delta.get("class") == "ReadReceipt":
|
||||
elif delta_class == "ReadReceipt":
|
||||
seen_by = str(delta.get("actorFbId") or delta["threadKey"]["otherUserFbId"])
|
||||
seen_ts = int(delta["actionTimestampMs"])
|
||||
delivered_ts = int(delta["watermarkTimestampMs"])
|
||||
@@ -2127,7 +2379,7 @@ class Client(object):
|
||||
seen_ts=seen_ts, ts=delivered_ts, metadata=metadata, msg=m)
|
||||
|
||||
# Messages marked as seen
|
||||
elif delta.get("class") == "MarkRead":
|
||||
elif delta_class == "MarkRead":
|
||||
seen_ts = int(delta.get("actionTimestampMs") or delta.get("actionTimestamp"))
|
||||
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,
|
||||
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
|
||||
elif delta.get("class") == "NewMessage":
|
||||
mentions = []
|
||||
@@ -2238,6 +2545,7 @@ class Client(object):
|
||||
|
||||
sticker = None
|
||||
attachments = []
|
||||
unsent = False
|
||||
if delta.get('attachments'):
|
||||
try:
|
||||
for a in delta['attachments']:
|
||||
@@ -2245,17 +2553,23 @@ class Client(object):
|
||||
if mercury.get('blob_attachment'):
|
||||
image_metadata = a.get('imageMetadata', {})
|
||||
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
|
||||
attachment.size = int(a['fileSize'])
|
||||
attachments.append(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'):
|
||||
# TODO: Add more data here for shared stuff (URLs, events and so on)
|
||||
pass
|
||||
attachment = graphql_to_extensible_attachment(mercury['extensible_attachment'])
|
||||
if isinstance(attachment, UnsentMessage):
|
||||
unsent = True
|
||||
elif attachment:
|
||||
attachments.append(attachment)
|
||||
|
||||
except Exception:
|
||||
log.exception('An exception occured while reading attachments: {}'.format(delta['attachments']))
|
||||
|
||||
@@ -2267,12 +2581,13 @@ class Client(object):
|
||||
mentions=mentions,
|
||||
emoji_size=emoji_size,
|
||||
sticker=sticker,
|
||||
attachments=attachments
|
||||
attachments=attachments,
|
||||
)
|
||||
message.uid = mid
|
||||
message.author = author_id
|
||||
message.timestamp = ts
|
||||
#message.reactions = {}
|
||||
message.unsent = unsent
|
||||
thread_id, thread_type = getThreadIdAndThreadType(metadata)
|
||||
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)
|
||||
@@ -2322,12 +2637,48 @@ class Client(object):
|
||||
|
||||
# Chat timestamp
|
||||
elif mtype == "chatproxy-presence":
|
||||
buddylist = {}
|
||||
buddylist = dict()
|
||||
for _id in m.get('buddyList', {}):
|
||||
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)
|
||||
|
||||
# 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
|
||||
else:
|
||||
self.onUnknownMesssageType(msg=m)
|
||||
@@ -2343,20 +2694,24 @@ class Client(object):
|
||||
"""
|
||||
self.listening = True
|
||||
|
||||
def doOneListen(self, markAlive=True):
|
||||
def doOneListen(self, markAlive=None):
|
||||
"""
|
||||
Does one cycle of the listening 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
|
||||
:type markAlive: bool
|
||||
.. warning::
|
||||
`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
|
||||
:rtype: bool
|
||||
"""
|
||||
if markAlive is not None:
|
||||
self._markAlive = markAlive
|
||||
try:
|
||||
if markAlive:
|
||||
if self._markAlive:
|
||||
self._ping()
|
||||
content = self._pullMessage(markAlive)
|
||||
content = self._pullMessage()
|
||||
if content:
|
||||
self._parseMessage(content)
|
||||
except KeyboardInterrupt:
|
||||
@@ -2383,21 +2738,33 @@ class Client(object):
|
||||
self.listening = False
|
||||
self.sticky, self.pool = (None, None)
|
||||
|
||||
def listen(self, markAlive=True):
|
||||
def listen(self, markAlive=None):
|
||||
"""
|
||||
Initializes and runs the listening loop continually
|
||||
|
||||
:param markAlive: Whether this should ping the Facebook server each time the loop runs
|
||||
:type markAlive: bool
|
||||
"""
|
||||
if markAlive is not None:
|
||||
self.setActiveStatus(markAlive)
|
||||
|
||||
self.startListening()
|
||||
self.onListening()
|
||||
|
||||
while self.listening and self.doOneListen(markAlive):
|
||||
while self.listening and self.doOneListen():
|
||||
pass
|
||||
|
||||
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
|
||||
"""
|
||||
@@ -2509,15 +2876,18 @@ class Client(object):
|
||||
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
|
||||
|
||||
: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 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_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("{} 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))
|
||||
|
||||
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):
|
||||
"""
|
||||
@@ -2709,40 +3092,78 @@ class Client(object):
|
||||
"""
|
||||
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 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
|
||||
: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
|
||||
: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
|
||||
: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):
|
||||
"""
|
||||
@@ -2914,6 +3335,7 @@ class Client(object):
|
||||
:param metadata: Extra metadata about the action
|
||||
:param msg: A full set of the data recieved
|
||||
:type plan: models.Plan
|
||||
:type take_part: bool
|
||||
:type thread_type: models.ThreadType
|
||||
"""
|
||||
if take_part:
|
||||
@@ -2921,6 +3343,51 @@ class Client(object):
|
||||
else:
|
||||
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
|
||||
"""
|
||||
|
@@ -26,12 +26,11 @@ class ConcatJSONDecoder(json.JSONDecoder):
|
||||
def graphql_color_to_enum(color):
|
||||
if color is None:
|
||||
return None
|
||||
if len(color) == 0:
|
||||
if not color:
|
||||
return ThreadColor.MESSENGER_BLUE
|
||||
try:
|
||||
return ThreadColor('#{}'.format(color[2:].lower()))
|
||||
except ValueError:
|
||||
raise FBchatException('Could not get ThreadColor from color: {}'.format(color))
|
||||
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:
|
||||
@@ -128,9 +127,84 @@ def graphql_to_attachment(a):
|
||||
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):
|
||||
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')]
|
||||
)
|
||||
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"]
|
||||
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'] = {}
|
||||
@@ -207,13 +299,27 @@ def graphql_to_message(message):
|
||||
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']):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:
|
||||
rtn.attachments = [graphql_to_attachment(attachment) for attachment in message['blob_attachments']]
|
||||
# TODO: This is still missing parsing:
|
||||
# message.get('extensible_attachment')
|
||||
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):
|
||||
@@ -477,7 +583,7 @@ class GraphQL(object):
|
||||
"""
|
||||
|
||||
SEARCH_USER = """
|
||||
Query SearchUser(<search> = '', <limit> = 1) {
|
||||
Query SearchUser(<search> = '', <limit> = 10) {
|
||||
entities_named(<search>) {
|
||||
search_results.of_type(user).first(<limit>) as users {
|
||||
nodes {
|
||||
@@ -489,7 +595,7 @@ class GraphQL(object):
|
||||
""" + FRAGMENT_USER
|
||||
|
||||
SEARCH_GROUP = """
|
||||
Query SearchGroup(<search> = '', <limit> = 1, <pic_size> = 32) {
|
||||
Query SearchGroup(<search> = '', <limit> = 10, <pic_size> = 32) {
|
||||
viewer() {
|
||||
message_threads.with_thread_name(<search>).last(<limit>) as groups {
|
||||
nodes {
|
||||
@@ -501,7 +607,7 @@ class GraphQL(object):
|
||||
""" + FRAGMENT_GROUP
|
||||
|
||||
SEARCH_PAGE = """
|
||||
Query SearchPage(<search> = '', <limit> = 1) {
|
||||
Query SearchPage(<search> = '', <limit> = 10) {
|
||||
entities_named(<search>) {
|
||||
search_results.of_type(page).first(<limit>) as pages {
|
||||
nodes {
|
||||
@@ -513,7 +619,7 @@ class GraphQL(object):
|
||||
""" + FRAGMENT_PAGE
|
||||
|
||||
SEARCH_THREAD = """
|
||||
Query SearchThread(<search> = '', <limit> = 1) {
|
||||
Query SearchThread(<search> = '', <limit> = 10) {
|
||||
entities_named(<search>) {
|
||||
search_results.first(<limit>) as threads {
|
||||
nodes {
|
||||
|
227
fbchat/models.py
227
fbchat/models.py
@@ -1,7 +1,8 @@
|
||||
# -*- coding: UTF-8 -*-
|
||||
|
||||
from __future__ import unicode_literals
|
||||
import enum
|
||||
import aenum
|
||||
from string import Formatter
|
||||
|
||||
|
||||
class FBchatException(Exception):
|
||||
@@ -190,8 +191,12 @@ class Message(object):
|
||||
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):
|
||||
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:
|
||||
@@ -202,8 +207,12 @@ class Message(object):
|
||||
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__()
|
||||
@@ -211,6 +220,52 @@ class Message(object):
|
||||
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
|
||||
@@ -219,6 +274,12 @@ class Attachment(object):
|
||||
"""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
|
||||
@@ -251,9 +312,79 @@ class Sticker(Attachment):
|
||||
super(Sticker, self).__init__(*args, **kwargs)
|
||||
|
||||
class ShareAttachment(Attachment):
|
||||
def __init__(self, **kwargs):
|
||||
"""Represents a shared item (eg. URL) that has been sent as a Facebook attachment - *Currently Incomplete!*"""
|
||||
#: 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
|
||||
@@ -442,6 +573,73 @@ class Mention(object):
|
||||
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
|
||||
@@ -523,7 +721,26 @@ class Plan(object):
|
||||
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 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"""
|
||||
def __repr__(self):
|
||||
# For documentation:
|
||||
|
@@ -11,13 +11,15 @@ from os.path import basename
|
||||
import warnings
|
||||
import logging
|
||||
import requests
|
||||
import aenum
|
||||
from .models import *
|
||||
|
||||
try:
|
||||
from urllib.parse import urlencode
|
||||
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
|
||||
@@ -140,6 +142,7 @@ class ReqUrl(object):
|
||||
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"
|
||||
|
||||
pull_channel = 0
|
||||
|
||||
@@ -297,3 +300,18 @@ def get_files_from_paths(filenames):
|
||||
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]
|
||||
|
52
pyproject.toml
Normal file
52
pyproject.toml
Normal 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",
|
||||
]
|
@@ -1,3 +0,0 @@
|
||||
requests
|
||||
beautifulsoup4
|
||||
enum34; python_version < '3.4'
|
@@ -1,5 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -ex
|
||||
|
||||
python -m pytest -m offline --color=yes
|
@@ -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
|
51
setup.cfg
51
setup.cfg
@@ -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'
|
8
setup.py
8
setup.py
@@ -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']})
|
@@ -19,6 +19,11 @@ def test_fetch_thread_list(client1):
|
||||
assert len(threads) == 2
|
||||
|
||||
|
||||
def test_fetch_threads(client1):
|
||||
threads = client1.fetchThreads(limit=2)
|
||||
assert len(threads) == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("emoji, emoji_size", EMOJI_LIST)
|
||||
def test_fetch_message_emoji(client, emoji, emoji_size):
|
||||
mid = client.sendEmoji(emoji, emoji_size)
|
||||
|
Reference in New Issue
Block a user