Compare commits
31 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
9c81806b95 | ||
|
45303005b8 | ||
|
881aa9adce | ||
|
4714be5697 | ||
|
cb7f4a72d7 | ||
|
fb63ff0db8 | ||
|
c5f447e20b | ||
|
b4d3769fd5 | ||
|
b199d597b2 | ||
|
debfb37a47 | ||
|
67fd6ffdf6 | ||
|
e57265016e | ||
|
cf4c22898c | ||
|
3bb99541e7 | ||
|
8c367af0ff | ||
|
bc1e3edf17 | ||
|
e488f4a7da | ||
|
afad38d8e1 | ||
|
e9804d4184 | ||
|
a1b80a7abb | ||
|
803bfa7084 | ||
|
d1cb866b44 | ||
|
a298e0cf16 | ||
|
766b0125fb | ||
|
998fa43fb2 | ||
|
ecc6edac5a | ||
|
ea518ba4c9 | ||
|
ffdf4222bf | ||
|
a97ef67411 | ||
|
813219cd9c | ||
|
bb1f7d9294 |
@@ -1,5 +1,5 @@
|
|||||||
[bumpversion]
|
[bumpversion]
|
||||||
current_version = 1.8.2
|
current_version = 1.9.4
|
||||||
commit = True
|
commit = True
|
||||||
tag = True
|
tag = True
|
||||||
|
|
||||||
|
@@ -13,7 +13,7 @@ from ._client import Client
|
|||||||
from ._util import log # TODO: Remove this (from examples too)
|
from ._util import log # TODO: Remove this (from examples too)
|
||||||
|
|
||||||
__title__ = "fbchat"
|
__title__ = "fbchat"
|
||||||
__version__ = "1.8.2"
|
__version__ = "1.9.4"
|
||||||
__description__ = "Facebook Chat (Messenger) for Python"
|
__description__ = "Facebook Chat (Messenger) for Python"
|
||||||
|
|
||||||
__copyright__ = "Copyright 2015 - 2019 by Taehoon Kim"
|
__copyright__ = "Copyright 2015 - 2019 by Taehoon Kim"
|
||||||
|
@@ -12,6 +12,7 @@ from ._util import *
|
|||||||
from .models import *
|
from .models import *
|
||||||
from . import _graphql
|
from . import _graphql
|
||||||
from ._state import State
|
from ._state import State
|
||||||
|
from ._mqtt import Mqtt
|
||||||
import time
|
import time
|
||||||
import json
|
import json
|
||||||
|
|
||||||
@@ -85,13 +86,11 @@ class Client(object):
|
|||||||
Raises:
|
Raises:
|
||||||
FBchatException: On failed login
|
FBchatException: On failed login
|
||||||
"""
|
"""
|
||||||
self._sticky, self._pool = (None, None)
|
|
||||||
self._seq = "0"
|
|
||||||
self._default_thread_id = None
|
self._default_thread_id = None
|
||||||
self._default_thread_type = None
|
self._default_thread_type = None
|
||||||
self._pull_channel = 0
|
|
||||||
self._markAlive = True
|
self._markAlive = True
|
||||||
self._buddylist = dict()
|
self._buddylist = dict()
|
||||||
|
self._mqtt = None
|
||||||
|
|
||||||
handler.setLevel(logging_level)
|
handler.setLevel(logging_level)
|
||||||
|
|
||||||
@@ -280,7 +279,7 @@ class Client(object):
|
|||||||
|
|
||||||
def _forcedFetch(self, thread_id, mid):
|
def _forcedFetch(self, thread_id, mid):
|
||||||
params = {"thread_and_message_id": {"thread_id": thread_id, "message_id": mid}}
|
params = {"thread_and_message_id": {"thread_id": thread_id, "message_id": mid}}
|
||||||
j, = self.graphql_requests(_graphql.from_doc_id("1768656253222505", params))
|
(j,) = self.graphql_requests(_graphql.from_doc_id("1768656253222505", params))
|
||||||
return j
|
return j
|
||||||
|
|
||||||
def fetchThreads(self, thread_location, before=None, after=None, limit=None):
|
def fetchThreads(self, thread_location, before=None, after=None, limit=None):
|
||||||
@@ -404,7 +403,7 @@ class Client(object):
|
|||||||
FBchatException: If request failed
|
FBchatException: If request failed
|
||||||
"""
|
"""
|
||||||
params = {"search": name, "limit": limit}
|
params = {"search": name, "limit": limit}
|
||||||
j, = self.graphql_requests(_graphql.from_query(_graphql.SEARCH_USER, params))
|
(j,) = self.graphql_requests(_graphql.from_query(_graphql.SEARCH_USER, params))
|
||||||
|
|
||||||
return [User._from_graphql(node) for node in j[name]["users"]["nodes"]]
|
return [User._from_graphql(node) for node in j[name]["users"]["nodes"]]
|
||||||
|
|
||||||
@@ -421,7 +420,7 @@ class Client(object):
|
|||||||
FBchatException: If request failed
|
FBchatException: If request failed
|
||||||
"""
|
"""
|
||||||
params = {"search": name, "limit": limit}
|
params = {"search": name, "limit": limit}
|
||||||
j, = self.graphql_requests(_graphql.from_query(_graphql.SEARCH_PAGE, params))
|
(j,) = self.graphql_requests(_graphql.from_query(_graphql.SEARCH_PAGE, params))
|
||||||
|
|
||||||
return [Page._from_graphql(node) for node in j[name]["pages"]["nodes"]]
|
return [Page._from_graphql(node) for node in j[name]["pages"]["nodes"]]
|
||||||
|
|
||||||
@@ -439,7 +438,7 @@ class Client(object):
|
|||||||
FBchatException: If request failed
|
FBchatException: If request failed
|
||||||
"""
|
"""
|
||||||
params = {"search": name, "limit": limit}
|
params = {"search": name, "limit": limit}
|
||||||
j, = self.graphql_requests(_graphql.from_query(_graphql.SEARCH_GROUP, params))
|
(j,) = self.graphql_requests(_graphql.from_query(_graphql.SEARCH_GROUP, params))
|
||||||
|
|
||||||
return [Group._from_graphql(node) for node in j["viewer"]["groups"]["nodes"]]
|
return [Group._from_graphql(node) for node in j["viewer"]["groups"]["nodes"]]
|
||||||
|
|
||||||
@@ -457,7 +456,9 @@ class Client(object):
|
|||||||
FBchatException: If request failed
|
FBchatException: If request failed
|
||||||
"""
|
"""
|
||||||
params = {"search": name, "limit": limit}
|
params = {"search": name, "limit": limit}
|
||||||
j, = self.graphql_requests(_graphql.from_query(_graphql.SEARCH_THREAD, params))
|
(j,) = self.graphql_requests(
|
||||||
|
_graphql.from_query(_graphql.SEARCH_THREAD, params)
|
||||||
|
)
|
||||||
|
|
||||||
rtn = []
|
rtn = []
|
||||||
for node in j[name]["threads"]["nodes"]:
|
for node in j[name]["threads"]["nodes"]:
|
||||||
@@ -764,7 +765,7 @@ class Client(object):
|
|||||||
"load_read_receipts": True,
|
"load_read_receipts": True,
|
||||||
"before": before,
|
"before": before,
|
||||||
}
|
}
|
||||||
j, = self.graphql_requests(_graphql.from_doc_id("1860982147341344", params))
|
(j,) = self.graphql_requests(_graphql.from_doc_id("1860982147341344", params))
|
||||||
|
|
||||||
if j.get("message_thread") is None:
|
if j.get("message_thread") is None:
|
||||||
raise FBchatException("Could not fetch thread {}: {}".format(thread_id, j))
|
raise FBchatException("Could not fetch thread {}: {}".format(thread_id, j))
|
||||||
@@ -823,7 +824,7 @@ class Client(object):
|
|||||||
"includeDeliveryReceipts": True,
|
"includeDeliveryReceipts": True,
|
||||||
"includeSeqID": False,
|
"includeSeqID": False,
|
||||||
}
|
}
|
||||||
j, = self.graphql_requests(_graphql.from_doc_id("1349387578499440", params))
|
(j,) = self.graphql_requests(_graphql.from_doc_id("1349387578499440", params))
|
||||||
|
|
||||||
rtn = []
|
rtn = []
|
||||||
for node in j["viewer"]["message_threads"]["nodes"]:
|
for node in j["viewer"]["message_threads"]["nodes"]:
|
||||||
@@ -943,7 +944,7 @@ class Client(object):
|
|||||||
return Plan._from_fetch(j)
|
return Plan._from_fetch(j)
|
||||||
|
|
||||||
def _getPrivateData(self):
|
def _getPrivateData(self):
|
||||||
j, = self.graphql_requests(_graphql.from_doc_id("1868889766468115", {}))
|
(j,) = self.graphql_requests(_graphql.from_doc_id("1868889766468115", {}))
|
||||||
return j["viewer"]
|
return j["viewer"]
|
||||||
|
|
||||||
def getPhoneNumbers(self):
|
def getPhoneNumbers(self):
|
||||||
@@ -994,7 +995,7 @@ class Client(object):
|
|||||||
thread_id, thread_type = self._getThread(thread_id, None)
|
thread_id, thread_type = self._getThread(thread_id, None)
|
||||||
data = {"id": thread_id, "first": 48}
|
data = {"id": thread_id, "first": 48}
|
||||||
thread_id = str(thread_id)
|
thread_id = str(thread_id)
|
||||||
j, = self.graphql_requests(_graphql.from_query_id("515216185516880", data))
|
(j,) = self.graphql_requests(_graphql.from_query_id("515216185516880", data))
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
i = j[thread_id]["message_shared_media"]["edges"][0]
|
i = j[thread_id]["message_shared_media"]["edges"][0]
|
||||||
@@ -1005,7 +1006,7 @@ class Client(object):
|
|||||||
data["after"] = j[thread_id]["message_shared_media"][
|
data["after"] = j[thread_id]["message_shared_media"][
|
||||||
"page_info"
|
"page_info"
|
||||||
].get("end_cursor")
|
].get("end_cursor")
|
||||||
j, = self.graphql_requests(
|
(j,) = self.graphql_requests(
|
||||||
_graphql.from_query_id("515216185516880", data)
|
_graphql.from_query_id("515216185516880", data)
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
@@ -1534,7 +1535,7 @@ class Client(object):
|
|||||||
"response": "ACCEPT" if approve else "DENY",
|
"response": "ACCEPT" if approve else "DENY",
|
||||||
"surface": "ADMIN_MODEL_APPROVAL_CENTER",
|
"surface": "ADMIN_MODEL_APPROVAL_CENTER",
|
||||||
}
|
}
|
||||||
j, = self.graphql_requests(
|
(j,) = self.graphql_requests(
|
||||||
_graphql.from_doc_id("1574519202665847", {"data": data})
|
_graphql.from_doc_id("1574519202665847", {"data": data})
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1589,7 +1590,7 @@ class Client(object):
|
|||||||
Raises:
|
Raises:
|
||||||
FBchatException: If request failed
|
FBchatException: If request failed
|
||||||
"""
|
"""
|
||||||
(image_id, mimetype), = self._upload(get_files_from_urls([image_url]))
|
((image_id, mimetype),) = self._upload(get_files_from_urls([image_url]))
|
||||||
return self._changeGroupImage(image_id, thread_id)
|
return self._changeGroupImage(image_id, thread_id)
|
||||||
|
|
||||||
def changeGroupImageLocal(self, image_path, thread_id=None):
|
def changeGroupImageLocal(self, image_path, thread_id=None):
|
||||||
@@ -1603,7 +1604,7 @@ class Client(object):
|
|||||||
FBchatException: If request failed
|
FBchatException: If request failed
|
||||||
"""
|
"""
|
||||||
with get_files_from_paths([image_path]) as files:
|
with get_files_from_paths([image_path]) as files:
|
||||||
(image_id, mimetype), = self._upload(files)
|
((image_id, mimetype),) = self._upload(files)
|
||||||
|
|
||||||
return self._changeGroupImage(image_id, thread_id)
|
return self._changeGroupImage(image_id, thread_id)
|
||||||
|
|
||||||
@@ -2167,39 +2168,7 @@ class Client(object):
|
|||||||
LISTEN METHODS
|
LISTEN METHODS
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def _ping(self):
|
def _parseDelta(self, delta):
|
||||||
data = {
|
|
||||||
"seq": self._seq,
|
|
||||||
"channel": "p_" + self._uid,
|
|
||||||
"clientid": self._state._client_id,
|
|
||||||
"partition": -2,
|
|
||||||
"cap": 0,
|
|
||||||
"uid": self._uid,
|
|
||||||
"sticky_token": self._sticky,
|
|
||||||
"sticky_pool": self._pool,
|
|
||||||
"viewer_uid": self._uid,
|
|
||||||
"state": "active",
|
|
||||||
}
|
|
||||||
j = self._get(
|
|
||||||
"https://{}-edge-chat.facebook.com/active_ping".format(self._pull_channel),
|
|
||||||
data,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _pullMessage(self):
|
|
||||||
"""Call pull api to fetch message data."""
|
|
||||||
data = {
|
|
||||||
"seq": self._seq,
|
|
||||||
"msgs_recv": 0,
|
|
||||||
"sticky_token": self._sticky,
|
|
||||||
"sticky_pool": self._pool,
|
|
||||||
"clientid": self._state._client_id,
|
|
||||||
"state": "active" if self._markAlive else "offline",
|
|
||||||
}
|
|
||||||
return self._get(
|
|
||||||
"https://{}-edge-chat.facebook.com/pull".format(self._pull_channel), data
|
|
||||||
)
|
|
||||||
|
|
||||||
def _parseDelta(self, m):
|
|
||||||
def getThreadIdAndThreadType(msg_metadata):
|
def getThreadIdAndThreadType(msg_metadata):
|
||||||
"""Return a tuple consisting of thread ID and thread type."""
|
"""Return a tuple consisting of thread ID and thread type."""
|
||||||
id_thread = None
|
id_thread = None
|
||||||
@@ -2212,7 +2181,6 @@ class Client(object):
|
|||||||
type_thread = ThreadType.USER
|
type_thread = ThreadType.USER
|
||||||
return id_thread, type_thread
|
return id_thread, type_thread
|
||||||
|
|
||||||
delta = m["delta"]
|
|
||||||
delta_type = delta.get("type")
|
delta_type = delta.get("type")
|
||||||
delta_class = delta.get("class")
|
delta_class = delta.get("class")
|
||||||
metadata = delta.get("messageMetadata")
|
metadata = delta.get("messageMetadata")
|
||||||
@@ -2232,7 +2200,7 @@ class Client(object):
|
|||||||
author_id=author_id,
|
author_id=author_id,
|
||||||
thread_id=thread_id,
|
thread_id=thread_id,
|
||||||
ts=ts,
|
ts=ts,
|
||||||
msg=m,
|
msg=delta,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Left/removed participants
|
# Left/removed participants
|
||||||
@@ -2245,7 +2213,7 @@ class Client(object):
|
|||||||
author_id=author_id,
|
author_id=author_id,
|
||||||
thread_id=thread_id,
|
thread_id=thread_id,
|
||||||
ts=ts,
|
ts=ts,
|
||||||
msg=m,
|
msg=delta,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Color change
|
# Color change
|
||||||
@@ -2260,9 +2228,15 @@ class Client(object):
|
|||||||
thread_type=thread_type,
|
thread_type=thread_type,
|
||||||
ts=ts,
|
ts=ts,
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
msg=m,
|
msg=delta,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
elif delta_class == "MarkFolderSeen":
|
||||||
|
locations = [
|
||||||
|
ThreadLocation(folder.lstrip("FOLDER_")) for folder in delta["folders"]
|
||||||
|
]
|
||||||
|
self._onSeen(locations=locations, ts=delta["timestamp"], msg=delta)
|
||||||
|
|
||||||
# Emoji change
|
# Emoji change
|
||||||
elif delta_type == "change_thread_icon":
|
elif delta_type == "change_thread_icon":
|
||||||
new_emoji = delta["untypedData"]["thread_icon"]
|
new_emoji = delta["untypedData"]["thread_icon"]
|
||||||
@@ -2275,7 +2249,7 @@ class Client(object):
|
|||||||
thread_type=thread_type,
|
thread_type=thread_type,
|
||||||
ts=ts,
|
ts=ts,
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
msg=m,
|
msg=delta,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Thread title change
|
# Thread title change
|
||||||
@@ -2290,14 +2264,14 @@ class Client(object):
|
|||||||
thread_type=thread_type,
|
thread_type=thread_type,
|
||||||
ts=ts,
|
ts=ts,
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
msg=m,
|
msg=delta,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Forced fetch
|
# Forced fetch
|
||||||
elif delta_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=delta)
|
||||||
else:
|
else:
|
||||||
thread_id = str(delta["threadKey"]["threadFbId"])
|
thread_id = str(delta["threadKey"]["threadFbId"])
|
||||||
fetch_info = self._forcedFetch(thread_id, mid)
|
fetch_info = self._forcedFetch(thread_id, mid)
|
||||||
@@ -2319,7 +2293,7 @@ class Client(object):
|
|||||||
thread_id=thread_id,
|
thread_id=thread_id,
|
||||||
thread_type=ThreadType.GROUP,
|
thread_type=ThreadType.GROUP,
|
||||||
ts=ts,
|
ts=ts,
|
||||||
msg=m,
|
msg=delta,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Nickname change
|
# Nickname change
|
||||||
@@ -2336,7 +2310,7 @@ class Client(object):
|
|||||||
thread_type=thread_type,
|
thread_type=thread_type,
|
||||||
ts=ts,
|
ts=ts,
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
msg=m,
|
msg=delta,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Admin added or removed in a group thread
|
# Admin added or removed in a group thread
|
||||||
@@ -2352,7 +2326,7 @@ class Client(object):
|
|||||||
thread_id=thread_id,
|
thread_id=thread_id,
|
||||||
thread_type=thread_type,
|
thread_type=thread_type,
|
||||||
ts=ts,
|
ts=ts,
|
||||||
msg=m,
|
msg=delta,
|
||||||
)
|
)
|
||||||
elif admin_event == "remove_admin":
|
elif admin_event == "remove_admin":
|
||||||
self.onAdminRemoved(
|
self.onAdminRemoved(
|
||||||
@@ -2362,7 +2336,7 @@ class Client(object):
|
|||||||
thread_id=thread_id,
|
thread_id=thread_id,
|
||||||
thread_type=thread_type,
|
thread_type=thread_type,
|
||||||
ts=ts,
|
ts=ts,
|
||||||
msg=m,
|
msg=delta,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Group approval mode change
|
# Group approval mode change
|
||||||
@@ -2376,7 +2350,7 @@ class Client(object):
|
|||||||
thread_id=thread_id,
|
thread_id=thread_id,
|
||||||
thread_type=thread_type,
|
thread_type=thread_type,
|
||||||
ts=ts,
|
ts=ts,
|
||||||
msg=m,
|
msg=delta,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Message delivered
|
# Message delivered
|
||||||
@@ -2394,7 +2368,7 @@ class Client(object):
|
|||||||
thread_type=thread_type,
|
thread_type=thread_type,
|
||||||
ts=ts,
|
ts=ts,
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
msg=m,
|
msg=delta,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Message seen
|
# Message seen
|
||||||
@@ -2410,7 +2384,7 @@ class Client(object):
|
|||||||
seen_ts=seen_ts,
|
seen_ts=seen_ts,
|
||||||
ts=delivered_ts,
|
ts=delivered_ts,
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
msg=m,
|
msg=delta,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Messages marked as seen
|
# Messages marked as seen
|
||||||
@@ -2431,7 +2405,11 @@ class Client(object):
|
|||||||
|
|
||||||
# thread_id, thread_type = getThreadIdAndThreadType(delta)
|
# thread_id, thread_type = getThreadIdAndThreadType(delta)
|
||||||
self.onMarkedSeen(
|
self.onMarkedSeen(
|
||||||
threads=threads, seen_ts=seen_ts, ts=delivered_ts, metadata=delta, msg=m
|
threads=threads,
|
||||||
|
seen_ts=seen_ts,
|
||||||
|
ts=delivered_ts,
|
||||||
|
metadata=delta,
|
||||||
|
msg=delta,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Game played
|
# Game played
|
||||||
@@ -2456,9 +2434,13 @@ class Client(object):
|
|||||||
thread_type=thread_type,
|
thread_type=thread_type,
|
||||||
ts=ts,
|
ts=ts,
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
msg=m,
|
msg=delta,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Skip "no operation" events
|
||||||
|
elif delta_class == "NoOp":
|
||||||
|
pass
|
||||||
|
|
||||||
# Group call started/ended
|
# Group call started/ended
|
||||||
elif delta_type == "rtc_call_log":
|
elif delta_type == "rtc_call_log":
|
||||||
thread_id, thread_type = getThreadIdAndThreadType(metadata)
|
thread_id, thread_type = getThreadIdAndThreadType(metadata)
|
||||||
@@ -2474,7 +2456,7 @@ class Client(object):
|
|||||||
thread_type=thread_type,
|
thread_type=thread_type,
|
||||||
ts=ts,
|
ts=ts,
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
msg=m,
|
msg=delta,
|
||||||
)
|
)
|
||||||
elif call_status == "call_ended":
|
elif call_status == "call_ended":
|
||||||
self.onCallEnded(
|
self.onCallEnded(
|
||||||
@@ -2486,7 +2468,7 @@ class Client(object):
|
|||||||
thread_type=thread_type,
|
thread_type=thread_type,
|
||||||
ts=ts,
|
ts=ts,
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
msg=m,
|
msg=delta,
|
||||||
)
|
)
|
||||||
|
|
||||||
# User joined to group call
|
# User joined to group call
|
||||||
@@ -2501,7 +2483,7 @@ class Client(object):
|
|||||||
thread_type=thread_type,
|
thread_type=thread_type,
|
||||||
ts=ts,
|
ts=ts,
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
msg=m,
|
msg=delta,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Group poll event
|
# Group poll event
|
||||||
@@ -2520,7 +2502,7 @@ class Client(object):
|
|||||||
thread_type=thread_type,
|
thread_type=thread_type,
|
||||||
ts=ts,
|
ts=ts,
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
msg=m,
|
msg=delta,
|
||||||
)
|
)
|
||||||
elif event_type == "update_vote":
|
elif event_type == "update_vote":
|
||||||
# User voted on group poll
|
# User voted on group poll
|
||||||
@@ -2536,7 +2518,7 @@ class Client(object):
|
|||||||
thread_type=thread_type,
|
thread_type=thread_type,
|
||||||
ts=ts,
|
ts=ts,
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
msg=m,
|
msg=delta,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Plan created
|
# Plan created
|
||||||
@@ -2550,7 +2532,7 @@ class Client(object):
|
|||||||
thread_type=thread_type,
|
thread_type=thread_type,
|
||||||
ts=ts,
|
ts=ts,
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
msg=m,
|
msg=delta,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Plan ended
|
# Plan ended
|
||||||
@@ -2563,7 +2545,7 @@ class Client(object):
|
|||||||
thread_type=thread_type,
|
thread_type=thread_type,
|
||||||
ts=ts,
|
ts=ts,
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
msg=m,
|
msg=delta,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Plan edited
|
# Plan edited
|
||||||
@@ -2577,7 +2559,7 @@ class Client(object):
|
|||||||
thread_type=thread_type,
|
thread_type=thread_type,
|
||||||
ts=ts,
|
ts=ts,
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
msg=m,
|
msg=delta,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Plan deleted
|
# Plan deleted
|
||||||
@@ -2591,7 +2573,7 @@ class Client(object):
|
|||||||
thread_type=thread_type,
|
thread_type=thread_type,
|
||||||
ts=ts,
|
ts=ts,
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
msg=m,
|
msg=delta,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Plan participation change
|
# Plan participation change
|
||||||
@@ -2607,13 +2589,13 @@ class Client(object):
|
|||||||
thread_type=thread_type,
|
thread_type=thread_type,
|
||||||
ts=ts,
|
ts=ts,
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
msg=m,
|
msg=delta,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Client payload (that weird numbers)
|
# Client payload (that weird numbers)
|
||||||
elif delta_class == "ClientPayload":
|
elif delta_class == "ClientPayload":
|
||||||
payload = json.loads("".join(chr(z) for z in delta["payload"]))
|
payload = json.loads("".join(chr(z) for z in delta["payload"]))
|
||||||
ts = m.get("ofd_ts")
|
ts = now() # Hack
|
||||||
for d in payload.get("deltas", []):
|
for d in payload.get("deltas", []):
|
||||||
|
|
||||||
# Message reaction
|
# Message reaction
|
||||||
@@ -2634,7 +2616,7 @@ class Client(object):
|
|||||||
thread_id=thread_id,
|
thread_id=thread_id,
|
||||||
thread_type=thread_type,
|
thread_type=thread_type,
|
||||||
ts=ts,
|
ts=ts,
|
||||||
msg=m,
|
msg=delta,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self.onReactionRemoved(
|
self.onReactionRemoved(
|
||||||
@@ -2643,7 +2625,7 @@ class Client(object):
|
|||||||
thread_id=thread_id,
|
thread_id=thread_id,
|
||||||
thread_type=thread_type,
|
thread_type=thread_type,
|
||||||
ts=ts,
|
ts=ts,
|
||||||
msg=m,
|
msg=delta,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Viewer status change
|
# Viewer status change
|
||||||
@@ -2660,7 +2642,7 @@ class Client(object):
|
|||||||
thread_id=thread_id,
|
thread_id=thread_id,
|
||||||
thread_type=thread_type,
|
thread_type=thread_type,
|
||||||
ts=ts,
|
ts=ts,
|
||||||
msg=m,
|
msg=delta,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self.onBlock(
|
self.onBlock(
|
||||||
@@ -2668,7 +2650,7 @@ class Client(object):
|
|||||||
thread_id=thread_id,
|
thread_id=thread_id,
|
||||||
thread_type=thread_type,
|
thread_type=thread_type,
|
||||||
ts=ts,
|
ts=ts,
|
||||||
msg=m,
|
msg=delta,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Live location info
|
# Live location info
|
||||||
@@ -2686,7 +2668,7 @@ class Client(object):
|
|||||||
thread_id=thread_id,
|
thread_id=thread_id,
|
||||||
thread_type=thread_type,
|
thread_type=thread_type,
|
||||||
ts=ts,
|
ts=ts,
|
||||||
msg=m,
|
msg=delta,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Message deletion
|
# Message deletion
|
||||||
@@ -2702,7 +2684,7 @@ class Client(object):
|
|||||||
thread_id=thread_id,
|
thread_id=thread_id,
|
||||||
thread_type=thread_type,
|
thread_type=thread_type,
|
||||||
ts=ts,
|
ts=ts,
|
||||||
msg=m,
|
msg=delta,
|
||||||
)
|
)
|
||||||
|
|
||||||
elif d.get("deltaMessageReply"):
|
elif d.get("deltaMessageReply"):
|
||||||
@@ -2721,7 +2703,7 @@ class Client(object):
|
|||||||
thread_type=thread_type,
|
thread_type=thread_type,
|
||||||
ts=message.timestamp,
|
ts=message.timestamp,
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
msg=m,
|
msg=delta,
|
||||||
)
|
)
|
||||||
|
|
||||||
# New message
|
# New message
|
||||||
@@ -2742,39 +2724,25 @@ class Client(object):
|
|||||||
thread_type=thread_type,
|
thread_type=thread_type,
|
||||||
ts=ts,
|
ts=ts,
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
msg=m,
|
msg=delta,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Unknown message type
|
# Unknown message type
|
||||||
else:
|
else:
|
||||||
self.onUnknownMesssageType(msg=m)
|
self.onUnknownMesssageType(msg=delta)
|
||||||
|
|
||||||
def _parseMessage(self, content):
|
def _parse_payload(self, topic, m):
|
||||||
"""Get message and author name from content.
|
|
||||||
|
|
||||||
May contain multiple messages in the content.
|
|
||||||
"""
|
|
||||||
self._seq = content.get("seq", "0")
|
|
||||||
|
|
||||||
if "lb_info" in content:
|
|
||||||
self._sticky = content["lb_info"]["sticky"]
|
|
||||||
self._pool = content["lb_info"]["pool"]
|
|
||||||
|
|
||||||
if "batches" in content:
|
|
||||||
for batch in content["batches"]:
|
|
||||||
self._parseMessage(batch)
|
|
||||||
|
|
||||||
if "ms" not in content:
|
|
||||||
return
|
|
||||||
|
|
||||||
for m in content["ms"]:
|
|
||||||
mtype = m.get("type")
|
|
||||||
try:
|
|
||||||
# Things that directly change chat
|
# Things that directly change chat
|
||||||
if mtype == "delta":
|
if topic == "/t_ms":
|
||||||
self._parseDelta(m)
|
if "deltas" not in m:
|
||||||
|
return
|
||||||
|
for delta in m["deltas"]:
|
||||||
|
self._parseDelta(delta)
|
||||||
|
|
||||||
|
# TODO: Remove old parsing below
|
||||||
|
|
||||||
# Inbox
|
# Inbox
|
||||||
elif mtype == "inbox":
|
elif topic == "inbox":
|
||||||
self.onInbox(
|
self.onInbox(
|
||||||
unseen=m["unseen"],
|
unseen=m["unseen"],
|
||||||
unread=m["unread"],
|
unread=m["unread"],
|
||||||
@@ -2783,19 +2751,15 @@ class Client(object):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Typing
|
# Typing
|
||||||
elif mtype == "typ" or mtype == "ttyp":
|
# /thread_typing {'sender_fbid': X, 'state': 1, 'type': 'typ', 'thread': 'Y'}
|
||||||
author_id = str(m.get("from"))
|
# /orca_typing_notifications {'type': 'typ', 'sender_fbid': X, 'state': 0}
|
||||||
thread_id = m.get("thread_fbid")
|
elif topic in ("/thread_typing", "/orca_typing_notifications"):
|
||||||
if thread_id:
|
author_id = str(m["sender_fbid"])
|
||||||
thread_type = ThreadType.GROUP
|
thread_id = m.get("thread", author_id)
|
||||||
thread_id = str(thread_id)
|
typing_status = TypingStatus(m.get("state"))
|
||||||
else:
|
thread_type = (
|
||||||
thread_type = ThreadType.USER
|
ThreadType.USER if thread_id == author_id else ThreadType.GROUP
|
||||||
if author_id == self._uid:
|
)
|
||||||
thread_id = m.get("to")
|
|
||||||
else:
|
|
||||||
thread_id = author_id
|
|
||||||
typing_status = TypingStatus(m.get("st"))
|
|
||||||
self.onTyping(
|
self.onTyping(
|
||||||
author_id=author_id,
|
author_id=author_id,
|
||||||
status=typing_status,
|
status=typing_status,
|
||||||
@@ -2804,55 +2768,40 @@ class Client(object):
|
|||||||
msg=m,
|
msg=m,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Delivered
|
# Other notifications
|
||||||
|
elif topic == "/legacy_web":
|
||||||
# Seen
|
# Friend request
|
||||||
# elif mtype == "m_read_receipt":
|
if m["type"] == "jewel_requests_add":
|
||||||
#
|
|
||||||
# self.onSeen(m.get('realtime_viewer_fbid'), m.get('reader'), m.get('time'))
|
|
||||||
|
|
||||||
elif mtype in ["jewel_requests_add"]:
|
|
||||||
from_id = m["from"]
|
from_id = m["from"]
|
||||||
|
# TODO: from_id = str(from_id)
|
||||||
self.onFriendRequest(from_id=from_id, msg=m)
|
self.onFriendRequest(from_id=from_id, msg=m)
|
||||||
|
else:
|
||||||
|
self.onUnknownMesssageType(msg=m)
|
||||||
|
|
||||||
# Happens on every login
|
# Chat timestamp / Buddylist overlay
|
||||||
elif mtype == "qprimer":
|
elif topic == "/orca_presence":
|
||||||
self.onQprimer(ts=m.get("made"), msg=m)
|
if m["list_type"] == "full":
|
||||||
|
self._buddylist = {} # Refresh internal list
|
||||||
|
|
||||||
# Is sent before any other message
|
|
||||||
elif mtype == "deltaflow":
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Chat timestamp
|
|
||||||
elif mtype == "chatproxy-presence":
|
|
||||||
statuses = dict()
|
statuses = dict()
|
||||||
for id_, data in m.get("buddyList", {}).items():
|
for data in m["list"]:
|
||||||
statuses[id_] = ActiveStatus._from_chatproxy_presence(id_, data)
|
user_id = str(data["u"])
|
||||||
self._buddylist[id_] = statuses[id_]
|
statuses[user_id] = ActiveStatus._from_orca_presence(data)
|
||||||
|
self._buddylist[user_id] = statuses[user_id]
|
||||||
|
|
||||||
|
# TODO: Which one should we call?
|
||||||
self.onChatTimestamp(buddylist=statuses, msg=m)
|
self.onChatTimestamp(buddylist=statuses, msg=m)
|
||||||
|
|
||||||
# Buddylist overlay
|
|
||||||
elif mtype == "buddylist_overlay":
|
|
||||||
statuses = dict()
|
|
||||||
for id_, data in m.get("overlay", {}).items():
|
|
||||||
old_in_game = None
|
|
||||||
if id_ in self._buddylist:
|
|
||||||
old_in_game = self._buddylist[id_].in_game
|
|
||||||
|
|
||||||
statuses[id_] = ActiveStatus._from_buddylist_overlay(
|
|
||||||
data, old_in_game
|
|
||||||
)
|
|
||||||
self._buddylist[id_] = statuses[id_]
|
|
||||||
|
|
||||||
self.onBuddylistOverlay(statuses=statuses, msg=m)
|
self.onBuddylistOverlay(statuses=statuses, msg=m)
|
||||||
|
|
||||||
# Unknown message type
|
# Unknown message type
|
||||||
else:
|
else:
|
||||||
self.onUnknownMesssageType(msg=m)
|
self.onUnknownMesssageType(msg=m)
|
||||||
|
|
||||||
|
def _parse_message(self, topic, data):
|
||||||
|
try:
|
||||||
|
self._parse_payload(topic, data)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.onMessageError(exception=e, msg=m)
|
self.onMessageError(exception=e, msg=data)
|
||||||
|
|
||||||
def startListening(self):
|
def startListening(self):
|
||||||
"""Start listening from an external event loop.
|
"""Start listening from an external event loop.
|
||||||
@@ -2860,6 +2809,15 @@ class Client(object):
|
|||||||
Raises:
|
Raises:
|
||||||
FBchatException: If request failed
|
FBchatException: If request failed
|
||||||
"""
|
"""
|
||||||
|
if not self._mqtt:
|
||||||
|
self._mqtt = Mqtt.connect(
|
||||||
|
state=self._state,
|
||||||
|
on_message=self._parse_message,
|
||||||
|
chat_on=self._markAlive,
|
||||||
|
foreground=True,
|
||||||
|
)
|
||||||
|
# Backwards compat
|
||||||
|
self.onQprimer(ts=now(), msg=None)
|
||||||
self.listening = True
|
self.listening = True
|
||||||
|
|
||||||
def doOneListen(self, markAlive=None):
|
def doOneListen(self, markAlive=None):
|
||||||
@@ -2877,36 +2835,23 @@ class Client(object):
|
|||||||
"""
|
"""
|
||||||
if markAlive is not None:
|
if markAlive is not None:
|
||||||
self._markAlive = markAlive
|
self._markAlive = markAlive
|
||||||
try:
|
|
||||||
if self._markAlive:
|
|
||||||
self._ping()
|
|
||||||
content = self._pullMessage()
|
|
||||||
if content:
|
|
||||||
self._parseMessage(content)
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
return False
|
|
||||||
except requests.Timeout:
|
|
||||||
pass
|
|
||||||
except requests.ConnectionError:
|
|
||||||
# If the client has lost their internet connection, keep trying every 30 seconds
|
|
||||||
time.sleep(30)
|
|
||||||
except FBchatFacebookError as e:
|
|
||||||
# Fix 502 and 503 pull errors
|
|
||||||
if e.request_status_code in [502, 503]:
|
|
||||||
# Bump pull channel, while contraining withing 0-4
|
|
||||||
self._pull_channel = (self._pull_channel + 1) % 5
|
|
||||||
self.startListening()
|
|
||||||
else:
|
|
||||||
raise e
|
|
||||||
except Exception as e:
|
|
||||||
return self.onListenError(exception=e)
|
|
||||||
|
|
||||||
return True
|
# TODO: Remove this wierd check, and let the user handle the chat_on parameter
|
||||||
|
if self._markAlive != self._mqtt._chat_on:
|
||||||
|
self._mqtt.set_chat_on(self._markAlive)
|
||||||
|
|
||||||
|
# TODO: Remove on_error param
|
||||||
|
return self._mqtt.loop_once(on_error=lambda e: self.onListenError(exception=e))
|
||||||
|
|
||||||
def stopListening(self):
|
def stopListening(self):
|
||||||
"""Clean up the variables from `Client.startListening`."""
|
"""Stop the listening loop."""
|
||||||
self.listening = False
|
self.listening = False
|
||||||
self._sticky, self._pool = (None, None)
|
if not self._mqtt:
|
||||||
|
return
|
||||||
|
self._mqtt.disconnect()
|
||||||
|
# TODO: Preserve the _mqtt object
|
||||||
|
# Currently, there's some issues when disconnecting
|
||||||
|
self._mqtt = None
|
||||||
|
|
||||||
def listen(self, markAlive=None):
|
def listen(self, markAlive=None):
|
||||||
"""Initialize and runs the listening loop continually.
|
"""Initialize and runs the listening loop continually.
|
||||||
@@ -3365,6 +3310,17 @@ class Client(object):
|
|||||||
"""
|
"""
|
||||||
log.info("Friend request from {}".format(from_id))
|
log.info("Friend request from {}".format(from_id))
|
||||||
|
|
||||||
|
def _onSeen(self, locations=None, ts=None, msg=None):
|
||||||
|
"""
|
||||||
|
Todo:
|
||||||
|
Document this, and make it public
|
||||||
|
|
||||||
|
Args:
|
||||||
|
locations: ---
|
||||||
|
ts: A timestamp of the action
|
||||||
|
msg: A full set of the data received
|
||||||
|
"""
|
||||||
|
|
||||||
def onInbox(self, unseen=None, unread=None, recent_unread=None, msg=None):
|
def onInbox(self, unseen=None, unread=None, recent_unread=None, msg=None):
|
||||||
"""
|
"""
|
||||||
Todo:
|
Todo:
|
||||||
@@ -3863,7 +3819,6 @@ class Client(object):
|
|||||||
statuses (dict): Dictionary with user IDs as keys and :class:`ActiveStatus` as values
|
statuses (dict): Dictionary with user IDs as keys and :class:`ActiveStatus` as values
|
||||||
msg: A full set of the data received
|
msg: A full set of the data received
|
||||||
"""
|
"""
|
||||||
log.debug("Buddylist overlay received: {}".format(statuses))
|
|
||||||
|
|
||||||
def onUnknownMesssageType(self, msg=None):
|
def onUnknownMesssageType(self, msg=None):
|
||||||
"""Called when the client is listening, and some unknown data was received.
|
"""Called when the client is listening, and some unknown data was received.
|
||||||
|
317
fbchat/_mqtt.py
Normal file
317
fbchat/_mqtt.py
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
import attr
|
||||||
|
import random
|
||||||
|
import paho.mqtt.client
|
||||||
|
from ._core import log
|
||||||
|
from . import _util, _exception, _graphql
|
||||||
|
|
||||||
|
|
||||||
|
def generate_session_id():
|
||||||
|
"""Generate a random session ID between 1 and 9007199254740991."""
|
||||||
|
return random.randint(1, 2 ** 53)
|
||||||
|
|
||||||
|
|
||||||
|
@attr.s(slots=True)
|
||||||
|
class Mqtt(object):
|
||||||
|
_state = attr.ib()
|
||||||
|
_mqtt = attr.ib()
|
||||||
|
_on_message = attr.ib()
|
||||||
|
_chat_on = attr.ib()
|
||||||
|
_foreground = attr.ib()
|
||||||
|
_sequence_id = attr.ib()
|
||||||
|
_sync_token = attr.ib(None)
|
||||||
|
|
||||||
|
_HOST = "edge-chat.facebook.com"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def connect(cls, state, on_message, chat_on, foreground):
|
||||||
|
mqtt = paho.mqtt.client.Client(
|
||||||
|
client_id="mqttwsclient",
|
||||||
|
clean_session=True,
|
||||||
|
protocol=paho.mqtt.client.MQTTv31,
|
||||||
|
transport="websockets",
|
||||||
|
)
|
||||||
|
mqtt.enable_logger()
|
||||||
|
# mqtt.max_inflight_messages_set(20) # The rest will get queued
|
||||||
|
# mqtt.max_queued_messages_set(0) # Unlimited messages can be queued
|
||||||
|
# mqtt.message_retry_set(20) # Retry sending for at least 20 seconds
|
||||||
|
# mqtt.reconnect_delay_set(min_delay=1, max_delay=120)
|
||||||
|
# TODO: Is region (lla | atn | odn | others?) important?
|
||||||
|
mqtt.tls_set()
|
||||||
|
|
||||||
|
self = cls(
|
||||||
|
state=state,
|
||||||
|
mqtt=mqtt,
|
||||||
|
on_message=on_message,
|
||||||
|
chat_on=chat_on,
|
||||||
|
foreground=foreground,
|
||||||
|
sequence_id=cls._fetch_sequence_id(state),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Configure callbacks
|
||||||
|
mqtt.on_message = self._on_message_handler
|
||||||
|
mqtt.on_connect = self._on_connect_handler
|
||||||
|
|
||||||
|
self._configure_connect_options()
|
||||||
|
|
||||||
|
# Attempt to connect
|
||||||
|
try:
|
||||||
|
rc = mqtt.connect(self._HOST, 443, keepalive=10)
|
||||||
|
except (
|
||||||
|
# Taken from .loop_forever
|
||||||
|
paho.mqtt.client.socket.error,
|
||||||
|
OSError,
|
||||||
|
paho.mqtt.client.WebsocketConnectionError,
|
||||||
|
) as e:
|
||||||
|
raise _exception.FBchatException("MQTT connection failed")
|
||||||
|
|
||||||
|
# Raise error if connecting failed
|
||||||
|
if rc != paho.mqtt.client.MQTT_ERR_SUCCESS:
|
||||||
|
err = paho.mqtt.client.error_string(rc)
|
||||||
|
raise _exception.FBchatException("MQTT connection failed: {}".format(err))
|
||||||
|
|
||||||
|
return self
|
||||||
|
|
||||||
|
def _on_message_handler(self, client, userdata, message):
|
||||||
|
# Parse payload JSON
|
||||||
|
try:
|
||||||
|
j = _util.parse_json(message.payload.decode("utf-8"))
|
||||||
|
except (_exception.FBchatFacebookError, UnicodeDecodeError):
|
||||||
|
log.exception("Failed parsing MQTT data on %s as JSON", message.topic)
|
||||||
|
return
|
||||||
|
|
||||||
|
if message.topic == "/t_ms":
|
||||||
|
# Update sync_token when received
|
||||||
|
# This is received in the first message after we've created a messenger
|
||||||
|
# sync queue.
|
||||||
|
if "syncToken" in j and "firstDeltaSeqId" in j:
|
||||||
|
self._sync_token = j["syncToken"]
|
||||||
|
self._sequence_id = j["firstDeltaSeqId"]
|
||||||
|
|
||||||
|
# Update last sequence id when received
|
||||||
|
if "lastIssuedSeqId" in j:
|
||||||
|
self._sequence_id = j["lastIssuedSeqId"]
|
||||||
|
|
||||||
|
if "errorCode" in j:
|
||||||
|
# Known types: ERROR_QUEUE_OVERFLOW | ERROR_QUEUE_NOT_FOUND
|
||||||
|
# 'F\xfa\x84\x8c\x85\xf8\xbc-\x88 FB_PAGES_INSUFFICIENT_PERMISSION\x00'
|
||||||
|
log.error("MQTT error code %s received", j["errorCode"])
|
||||||
|
# TODO: Consider resetting the sync_token and sequence ID here?
|
||||||
|
|
||||||
|
log.debug("MQTT payload: %s, %s", message.topic, j)
|
||||||
|
|
||||||
|
# Call the external callback
|
||||||
|
self._on_message(message.topic, j)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _fetch_sequence_id(state):
|
||||||
|
"""Fetch sequence ID."""
|
||||||
|
params = {
|
||||||
|
"limit": 1,
|
||||||
|
"tags": ["INBOX"],
|
||||||
|
"before": None,
|
||||||
|
"includeDeliveryReceipts": False,
|
||||||
|
"includeSeqID": True,
|
||||||
|
}
|
||||||
|
log.debug("Fetching MQTT sequence ID")
|
||||||
|
# Same request as in `Client.fetchThreadList`
|
||||||
|
(j,) = state._graphql_requests(_graphql.from_doc_id("1349387578499440", params))
|
||||||
|
try:
|
||||||
|
return int(j["viewer"]["message_threads"]["sync_sequence_id"])
|
||||||
|
except (KeyError, ValueError):
|
||||||
|
# TODO: Proper exceptions
|
||||||
|
raise
|
||||||
|
|
||||||
|
def _on_connect_handler(self, client, userdata, flags, rc):
|
||||||
|
if rc == 21:
|
||||||
|
raise _exception.FBchatException(
|
||||||
|
"Failed connecting. Maybe your cookies are wrong?"
|
||||||
|
)
|
||||||
|
if rc != 0:
|
||||||
|
return # Don't try to send publish if the connection failed
|
||||||
|
|
||||||
|
# configure receiving messages.
|
||||||
|
payload = {
|
||||||
|
"sync_api_version": 10,
|
||||||
|
"max_deltas_able_to_process": 1000,
|
||||||
|
"delta_batch_size": 500,
|
||||||
|
"encoding": "JSON",
|
||||||
|
"entity_fbid": self._state.user_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
# If we don't have a sync_token, create a new messenger queue
|
||||||
|
# This is done so that across reconnects, if we've received a sync token, we
|
||||||
|
# SHOULD receive a piece of data in /t_ms exactly once!
|
||||||
|
if self._sync_token is None:
|
||||||
|
topic = "/messenger_sync_create_queue"
|
||||||
|
payload["initial_titan_sequence_id"] = str(self._sequence_id)
|
||||||
|
payload["device_params"] = None
|
||||||
|
else:
|
||||||
|
topic = "/messenger_sync_get_diffs"
|
||||||
|
payload["last_seq_id"] = str(self._sequence_id)
|
||||||
|
payload["sync_token"] = self._sync_token
|
||||||
|
|
||||||
|
self._mqtt.publish(topic, _util.json_minimal(payload), qos=1)
|
||||||
|
|
||||||
|
def _configure_connect_options(self):
|
||||||
|
# Generate a new session ID on each reconnect
|
||||||
|
session_id = generate_session_id()
|
||||||
|
|
||||||
|
topics = [
|
||||||
|
# Things that happen in chats (e.g. messages)
|
||||||
|
"/t_ms",
|
||||||
|
# Group typing notifications
|
||||||
|
"/thread_typing",
|
||||||
|
# Private chat typing notifications
|
||||||
|
"/orca_typing_notifications",
|
||||||
|
# Active notifications
|
||||||
|
"/orca_presence",
|
||||||
|
# Other notifications not related to chats (e.g. friend requests)
|
||||||
|
"/legacy_web",
|
||||||
|
# Facebook's continuous error reporting/logging?
|
||||||
|
"/br_sr",
|
||||||
|
# Response to /br_sr
|
||||||
|
"/sr_res",
|
||||||
|
# TODO: Investigate the response from this! (A bunch of binary data)
|
||||||
|
# "/t_p",
|
||||||
|
# TODO: Find out what this does!
|
||||||
|
"/webrtc",
|
||||||
|
# TODO: Find out what this does!
|
||||||
|
"/onevc",
|
||||||
|
# TODO: Find out what this does!
|
||||||
|
"/notify_disconnect",
|
||||||
|
# Old, no longer active topics
|
||||||
|
# These are here just in case something interesting pops up
|
||||||
|
"/inbox",
|
||||||
|
"/mercury",
|
||||||
|
"/messaging_events",
|
||||||
|
"/orca_message_notifications",
|
||||||
|
"/pp",
|
||||||
|
"/t_rtc",
|
||||||
|
"/webrtc_response",
|
||||||
|
]
|
||||||
|
|
||||||
|
username = {
|
||||||
|
# The user ID
|
||||||
|
"u": self._state.user_id,
|
||||||
|
# Session ID
|
||||||
|
"s": session_id,
|
||||||
|
# Active status setting
|
||||||
|
"chat_on": self._chat_on,
|
||||||
|
# foreground_state - Whether the window is focused
|
||||||
|
"fg": self._foreground,
|
||||||
|
# Can be any random ID
|
||||||
|
"d": self._state._client_id,
|
||||||
|
# Application ID, taken from facebook.com
|
||||||
|
"aid": 219994525426954,
|
||||||
|
# MQTT extension by FB, allows making a SUBSCRIBE while CONNECTing
|
||||||
|
"st": topics,
|
||||||
|
# MQTT extension by FB, allows making a PUBLISH while CONNECTing
|
||||||
|
# Using this is more efficient, but the same can be acheived with:
|
||||||
|
# def on_connect(*args):
|
||||||
|
# mqtt.publish(topic, payload, qos=1)
|
||||||
|
# mqtt.on_connect = on_connect
|
||||||
|
# TODO: For some reason this doesn't work!
|
||||||
|
"pm": [
|
||||||
|
# {
|
||||||
|
# "topic": topic,
|
||||||
|
# "payload": payload,
|
||||||
|
# "qos": 1,
|
||||||
|
# "messageId": 65536,
|
||||||
|
# }
|
||||||
|
],
|
||||||
|
# Unknown parameters
|
||||||
|
"cp": 3,
|
||||||
|
"ecp": 10,
|
||||||
|
"ct": "websocket",
|
||||||
|
"mqtt_sid": "",
|
||||||
|
"dc": "",
|
||||||
|
"no_auto_fg": True,
|
||||||
|
"gas": None,
|
||||||
|
"pack": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
# TODO: Make this thread safe
|
||||||
|
self._mqtt.username_pw_set(_util.json_minimal(username))
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
# TODO: Make this access thread safe
|
||||||
|
"Cookie": _util.get_cookie_header(
|
||||||
|
self._state._session, "https://edge-chat.facebook.com/chat"
|
||||||
|
),
|
||||||
|
"User-Agent": self._state._session.headers["User-Agent"],
|
||||||
|
"Origin": "https://www.facebook.com",
|
||||||
|
"Host": self._HOST,
|
||||||
|
}
|
||||||
|
|
||||||
|
self._mqtt.ws_set_options(
|
||||||
|
path="/chat?sid={}".format(session_id), headers=headers
|
||||||
|
)
|
||||||
|
|
||||||
|
def loop_once(self, on_error=None):
|
||||||
|
"""Run the listening loop once.
|
||||||
|
|
||||||
|
Returns whether to keep listening or not.
|
||||||
|
"""
|
||||||
|
rc = self._mqtt.loop(timeout=1.0)
|
||||||
|
|
||||||
|
# If disconnect() has been called
|
||||||
|
if self._mqtt._state == paho.mqtt.client.mqtt_cs_disconnecting:
|
||||||
|
return False # Stop listening
|
||||||
|
|
||||||
|
if rc != paho.mqtt.client.MQTT_ERR_SUCCESS:
|
||||||
|
# If known/expected error
|
||||||
|
if rc == paho.mqtt.client.MQTT_ERR_CONN_LOST:
|
||||||
|
log.warning("Connection lost, retrying")
|
||||||
|
elif rc == paho.mqtt.client.MQTT_ERR_NOMEM:
|
||||||
|
# This error is wrongly classified
|
||||||
|
# See https://github.com/eclipse/paho.mqtt.python/issues/340
|
||||||
|
log.warning("Connection error, retrying")
|
||||||
|
else:
|
||||||
|
err = paho.mqtt.client.error_string(rc)
|
||||||
|
log.error("MQTT Error: %s", err)
|
||||||
|
# For backwards compatibility
|
||||||
|
if on_error:
|
||||||
|
on_error(_exception.FBchatException("MQTT Error {}".format(err)))
|
||||||
|
|
||||||
|
# Wait before reconnecting
|
||||||
|
self._mqtt._reconnect_wait()
|
||||||
|
|
||||||
|
# Try reconnecting
|
||||||
|
self._configure_connect_options()
|
||||||
|
try:
|
||||||
|
self._mqtt.reconnect()
|
||||||
|
except (
|
||||||
|
# Taken from .loop_forever
|
||||||
|
paho.mqtt.client.socket.error,
|
||||||
|
OSError,
|
||||||
|
paho.mqtt.client.WebsocketConnectionError,
|
||||||
|
) as e:
|
||||||
|
log.debug("MQTT reconnection failed: %s", e)
|
||||||
|
|
||||||
|
return True # Keep listening
|
||||||
|
|
||||||
|
def disconnect(self):
|
||||||
|
self._mqtt.disconnect()
|
||||||
|
|
||||||
|
def set_foreground(self, value):
|
||||||
|
payload = _util.json_minimal({"foreground": value})
|
||||||
|
info = self._mqtt.publish("/foreground_state", payload=payload, qos=1)
|
||||||
|
self._foreground = value
|
||||||
|
# TODO: We can't wait for this, since the loop is running with .loop_forever()
|
||||||
|
# info.wait_for_publish()
|
||||||
|
|
||||||
|
def set_chat_on(self, value):
|
||||||
|
# TODO: Is this the right request to make?
|
||||||
|
data = {"make_user_available_when_in_foreground": value}
|
||||||
|
payload = _util.json_minimal(data)
|
||||||
|
info = self._mqtt.publish("/set_client_settings", payload=payload, qos=1)
|
||||||
|
self._chat_on = value
|
||||||
|
# TODO: We can't wait for this, since the loop is running with .loop_forever()
|
||||||
|
# info.wait_for_publish()
|
||||||
|
|
||||||
|
# def send_additional_contacts(self, additional_contacts):
|
||||||
|
# payload = _util.json_minimal({"additional_contacts": additional_contacts})
|
||||||
|
# info = self._mqtt.publish("/send_additional_contacts", payload=payload, qos=1)
|
||||||
|
#
|
||||||
|
# def browser_close(self):
|
||||||
|
# info = self._mqtt.publish("/browser_close", payload=b"{}", qos=1)
|
@@ -192,17 +192,6 @@ class ActiveStatus(object):
|
|||||||
in_game = attr.ib(None)
|
in_game = attr.ib(None)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _from_chatproxy_presence(cls, id_, data):
|
def _from_orca_presence(cls, data):
|
||||||
return cls(
|
# TODO: Handle `c` and `vc` keys (Probably some binary data)
|
||||||
active=data["p"] in [2, 3] if "p" in data else None,
|
return cls(active=data["p"] in [2, 3], last_active=data.get("l"), in_game=None)
|
||||||
last_active=data.get("lat"),
|
|
||||||
in_game=int(id_) in data.get("gamers", {}),
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _from_buddylist_overlay(cls, data, in_game=None):
|
|
||||||
return cls(
|
|
||||||
active=data["a"] in [2, 3] if "a" in data else None,
|
|
||||||
last_active=data.get("la"),
|
|
||||||
in_game=None,
|
|
||||||
)
|
|
||||||
|
@@ -57,6 +57,11 @@ def now():
|
|||||||
return int(time() * 1000)
|
return int(time() * 1000)
|
||||||
|
|
||||||
|
|
||||||
|
def json_minimal(data):
|
||||||
|
"""Get JSON data in minimal form."""
|
||||||
|
return json.dumps(data, separators=(",", ":"))
|
||||||
|
|
||||||
|
|
||||||
def strip_json_cruft(text):
|
def strip_json_cruft(text):
|
||||||
"""Removes `for(;;);` (and other cruft) that preceeds JSON responses."""
|
"""Removes `for(;;);` (and other cruft) that preceeds JSON responses."""
|
||||||
try:
|
try:
|
||||||
@@ -65,6 +70,14 @@ def strip_json_cruft(text):
|
|||||||
raise FBchatException("No JSON object found: {!r}".format(text))
|
raise FBchatException("No JSON object found: {!r}".format(text))
|
||||||
|
|
||||||
|
|
||||||
|
def get_cookie_header(session, url):
|
||||||
|
"""Extract a cookie header from a requests session."""
|
||||||
|
# The cookies are extracted this way to make sure they're escaped correctly
|
||||||
|
return requests.cookies.get_cookie_header(
|
||||||
|
session.cookies, requests.Request("GET", url),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_decoded_r(r):
|
def get_decoded_r(r):
|
||||||
return get_decoded(r._content)
|
return get_decoded(r._content)
|
||||||
|
|
||||||
@@ -219,11 +232,12 @@ def get_files_from_urls(file_urls):
|
|||||||
r = requests.get(file_url)
|
r = requests.get(file_url)
|
||||||
# We could possibly use r.headers.get('Content-Disposition'), see
|
# We could possibly use r.headers.get('Content-Disposition'), see
|
||||||
# https://stackoverflow.com/a/37060758
|
# https://stackoverflow.com/a/37060758
|
||||||
|
file_name = basename(file_url).split("?")[0].split("#")[0]
|
||||||
files.append(
|
files.append(
|
||||||
(
|
(
|
||||||
basename(file_url).split("?")[0].split("#")[0],
|
file_name,
|
||||||
r.content,
|
r.content,
|
||||||
r.headers.get("Content-Type") or guess_type(file_url)[0],
|
r.headers.get("Content-Type") or guess_type(file_name)[0],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return files
|
return files
|
||||||
|
@@ -17,6 +17,7 @@ requires = [
|
|||||||
"attrs>=18.2",
|
"attrs>=18.2",
|
||||||
"requests~=2.19",
|
"requests~=2.19",
|
||||||
"beautifulsoup4~=4.0",
|
"beautifulsoup4~=4.0",
|
||||||
|
"paho-mqtt~=1.5",
|
||||||
]
|
]
|
||||||
description-file = "README.rst"
|
description-file = "README.rst"
|
||||||
classifiers = [
|
classifiers = [
|
||||||
|
@@ -27,7 +27,7 @@ def test_fetch_threads(client1):
|
|||||||
@pytest.mark.parametrize("emoji, emoji_size", EMOJI_LIST)
|
@pytest.mark.parametrize("emoji, emoji_size", EMOJI_LIST)
|
||||||
def test_fetch_message_emoji(client, emoji, emoji_size):
|
def test_fetch_message_emoji(client, emoji, emoji_size):
|
||||||
mid = client.sendEmoji(emoji, emoji_size)
|
mid = client.sendEmoji(emoji, emoji_size)
|
||||||
message, = client.fetchThreadMessages(limit=1)
|
(message,) = client.fetchThreadMessages(limit=1)
|
||||||
|
|
||||||
assert subset(
|
assert subset(
|
||||||
vars(message), uid=mid, author=client.uid, text=emoji, emoji_size=emoji_size
|
vars(message), uid=mid, author=client.uid, text=emoji, emoji_size=emoji_size
|
||||||
@@ -46,7 +46,7 @@ def test_fetch_message_info_emoji(client, thread, emoji, emoji_size):
|
|||||||
|
|
||||||
def test_fetch_message_mentions(client, thread, message_with_mentions):
|
def test_fetch_message_mentions(client, thread, message_with_mentions):
|
||||||
mid = client.send(message_with_mentions)
|
mid = client.send(message_with_mentions)
|
||||||
message, = client.fetchThreadMessages(limit=1)
|
(message,) = client.fetchThreadMessages(limit=1)
|
||||||
|
|
||||||
assert subset(
|
assert subset(
|
||||||
vars(message), uid=mid, author=client.uid, text=message_with_mentions.text
|
vars(message), uid=mid, author=client.uid, text=message_with_mentions.text
|
||||||
@@ -71,7 +71,7 @@ def test_fetch_message_info_mentions(client, thread, message_with_mentions):
|
|||||||
@pytest.mark.parametrize("sticker", STICKER_LIST)
|
@pytest.mark.parametrize("sticker", STICKER_LIST)
|
||||||
def test_fetch_message_sticker(client, sticker):
|
def test_fetch_message_sticker(client, sticker):
|
||||||
mid = client.send(Message(sticker=sticker))
|
mid = client.send(Message(sticker=sticker))
|
||||||
message, = client.fetchThreadMessages(limit=1)
|
(message,) = client.fetchThreadMessages(limit=1)
|
||||||
|
|
||||||
assert subset(vars(message), uid=mid, author=client.uid)
|
assert subset(vars(message), uid=mid, author=client.uid)
|
||||||
assert subset(vars(message.sticker), uid=sticker.uid)
|
assert subset(vars(message.sticker), uid=sticker.uid)
|
||||||
@@ -96,6 +96,6 @@ def test_fetch_info(client1, group):
|
|||||||
|
|
||||||
def test_fetch_image_url(client):
|
def test_fetch_image_url(client):
|
||||||
client.sendLocalFiles([path.join(path.dirname(__file__), "resources", "image.png")])
|
client.sendLocalFiles([path.join(path.dirname(__file__), "resources", "image.png")])
|
||||||
message, = client.fetchThreadMessages(limit=1)
|
(message,) = client.fetchThreadMessages(limit=1)
|
||||||
|
|
||||||
assert client.fetchImageUrl(message.attachments[0].uid)
|
assert client.fetchImageUrl(message.attachments[0].uid)
|
||||||
|
@@ -19,5 +19,5 @@ def test_delete_messages(client):
|
|||||||
mid1 = client.sendMessage(text1)
|
mid1 = client.sendMessage(text1)
|
||||||
mid2 = client.sendMessage(text2)
|
mid2 = client.sendMessage(text2)
|
||||||
client.deleteMessages(mid2)
|
client.deleteMessages(mid2)
|
||||||
message, = client.fetchThreadMessages(limit=1)
|
(message,) = client.fetchThreadMessages(limit=1)
|
||||||
assert subset(vars(message), uid=mid1, author=client.uid, text=text1)
|
assert subset(vars(message), uid=mid1, author=client.uid, text=text1)
|
||||||
|
@@ -63,7 +63,7 @@ def test_create_poll(client1, group, catch_event, poll_data):
|
|||||||
for recv_option in event[
|
for recv_option in event[
|
||||||
"poll"
|
"poll"
|
||||||
].options: # The recieved options may not be the full list
|
].options: # The recieved options may not be the full list
|
||||||
old_option, = list(filter(lambda o: o.text == recv_option.text, poll.options))
|
(old_option,) = list(filter(lambda o: o.text == recv_option.text, poll.options))
|
||||||
voters = [client1.uid] if old_option.vote else []
|
voters = [client1.uid] if old_option.vote else []
|
||||||
assert subset(
|
assert subset(
|
||||||
vars(recv_option), voters=voters, votes_count=len(voters), vote=False
|
vars(recv_option), voters=voters, votes_count=len(voters), vote=False
|
||||||
|
Reference in New Issue
Block a user