diff --git a/fbchat/_client.py b/fbchat/_client.py index 42b0dba..3bac889 100644 --- a/fbchat/_client.py +++ b/fbchat/_client.py @@ -12,6 +12,7 @@ from ._util import * from .models import * from . import _graphql from ._state import State +from ._mqtt import Mqtt import time import json @@ -85,13 +86,11 @@ class Client(object): Raises: FBchatException: On failed login """ - self._sticky, self._pool = (None, None) - self._seq = "0" self._default_thread_id = None self._default_thread_type = None - self._pull_channel = 0 self._markAlive = True self._buddylist = dict() + self._mqtt = None handler.setLevel(logging_level) @@ -2169,39 +2168,7 @@ class Client(object): LISTEN METHODS """ - def _ping(self): - 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 _parseDelta(self, delta): def getThreadIdAndThreadType(msg_metadata): """Return a tuple consisting of thread ID and thread type.""" id_thread = None @@ -2214,7 +2181,6 @@ class Client(object): type_thread = ThreadType.USER return id_thread, type_thread - delta = m["delta"] delta_type = delta.get("type") delta_class = delta.get("class") metadata = delta.get("messageMetadata") @@ -2234,7 +2200,7 @@ class Client(object): author_id=author_id, thread_id=thread_id, ts=ts, - msg=m, + msg=delta, ) # Left/removed participants @@ -2247,7 +2213,7 @@ class Client(object): author_id=author_id, thread_id=thread_id, ts=ts, - msg=m, + msg=delta, ) # Color change @@ -2262,9 +2228,15 @@ class Client(object): thread_type=thread_type, ts=ts, 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 elif delta_type == "change_thread_icon": new_emoji = delta["untypedData"]["thread_icon"] @@ -2277,7 +2249,7 @@ class Client(object): thread_type=thread_type, ts=ts, metadata=metadata, - msg=m, + msg=delta, ) # Thread title change @@ -2292,14 +2264,14 @@ class Client(object): thread_type=thread_type, ts=ts, metadata=metadata, - msg=m, + msg=delta, ) # Forced fetch elif delta_class == "ForcedFetch": mid = delta.get("messageId") if mid is None: - self.onUnknownMesssageType(msg=m) + self.onUnknownMesssageType(msg=delta) else: thread_id = str(delta["threadKey"]["threadFbId"]) fetch_info = self._forcedFetch(thread_id, mid) @@ -2321,7 +2293,7 @@ class Client(object): thread_id=thread_id, thread_type=ThreadType.GROUP, ts=ts, - msg=m, + msg=delta, ) # Nickname change @@ -2338,7 +2310,7 @@ class Client(object): thread_type=thread_type, ts=ts, metadata=metadata, - msg=m, + msg=delta, ) # Admin added or removed in a group thread @@ -2354,7 +2326,7 @@ class Client(object): thread_id=thread_id, thread_type=thread_type, ts=ts, - msg=m, + msg=delta, ) elif admin_event == "remove_admin": self.onAdminRemoved( @@ -2364,7 +2336,7 @@ class Client(object): thread_id=thread_id, thread_type=thread_type, ts=ts, - msg=m, + msg=delta, ) # Group approval mode change @@ -2378,7 +2350,7 @@ class Client(object): thread_id=thread_id, thread_type=thread_type, ts=ts, - msg=m, + msg=delta, ) # Message delivered @@ -2396,7 +2368,7 @@ class Client(object): thread_type=thread_type, ts=ts, metadata=metadata, - msg=m, + msg=delta, ) # Message seen @@ -2412,7 +2384,7 @@ class Client(object): seen_ts=seen_ts, ts=delivered_ts, metadata=metadata, - msg=m, + msg=delta, ) # Messages marked as seen @@ -2433,7 +2405,11 @@ class Client(object): # thread_id, thread_type = getThreadIdAndThreadType(delta) 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 @@ -2458,9 +2434,13 @@ class Client(object): thread_type=thread_type, ts=ts, metadata=metadata, - msg=m, + msg=delta, ) + # Skip "no operation" events + elif delta_class == "NoOp": + pass + # Group call started/ended elif delta_type == "rtc_call_log": thread_id, thread_type = getThreadIdAndThreadType(metadata) @@ -2476,7 +2456,7 @@ class Client(object): thread_type=thread_type, ts=ts, metadata=metadata, - msg=m, + msg=delta, ) elif call_status == "call_ended": self.onCallEnded( @@ -2488,7 +2468,7 @@ class Client(object): thread_type=thread_type, ts=ts, metadata=metadata, - msg=m, + msg=delta, ) # User joined to group call @@ -2503,7 +2483,7 @@ class Client(object): thread_type=thread_type, ts=ts, metadata=metadata, - msg=m, + msg=delta, ) # Group poll event @@ -2522,7 +2502,7 @@ class Client(object): thread_type=thread_type, ts=ts, metadata=metadata, - msg=m, + msg=delta, ) elif event_type == "update_vote": # User voted on group poll @@ -2538,7 +2518,7 @@ class Client(object): thread_type=thread_type, ts=ts, metadata=metadata, - msg=m, + msg=delta, ) # Plan created @@ -2552,7 +2532,7 @@ class Client(object): thread_type=thread_type, ts=ts, metadata=metadata, - msg=m, + msg=delta, ) # Plan ended @@ -2565,7 +2545,7 @@ class Client(object): thread_type=thread_type, ts=ts, metadata=metadata, - msg=m, + msg=delta, ) # Plan edited @@ -2579,7 +2559,7 @@ class Client(object): thread_type=thread_type, ts=ts, metadata=metadata, - msg=m, + msg=delta, ) # Plan deleted @@ -2593,7 +2573,7 @@ class Client(object): thread_type=thread_type, ts=ts, metadata=metadata, - msg=m, + msg=delta, ) # Plan participation change @@ -2609,13 +2589,13 @@ class Client(object): thread_type=thread_type, ts=ts, metadata=metadata, - msg=m, + msg=delta, ) # 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") + ts = now() # Hack for d in payload.get("deltas", []): # Message reaction @@ -2636,7 +2616,7 @@ class Client(object): thread_id=thread_id, thread_type=thread_type, ts=ts, - msg=m, + msg=delta, ) else: self.onReactionRemoved( @@ -2645,7 +2625,7 @@ class Client(object): thread_id=thread_id, thread_type=thread_type, ts=ts, - msg=m, + msg=delta, ) # Viewer status change @@ -2662,7 +2642,7 @@ class Client(object): thread_id=thread_id, thread_type=thread_type, ts=ts, - msg=m, + msg=delta, ) else: self.onBlock( @@ -2670,7 +2650,7 @@ class Client(object): thread_id=thread_id, thread_type=thread_type, ts=ts, - msg=m, + msg=delta, ) # Live location info @@ -2688,7 +2668,7 @@ class Client(object): thread_id=thread_id, thread_type=thread_type, ts=ts, - msg=m, + msg=delta, ) # Message deletion @@ -2704,7 +2684,7 @@ class Client(object): thread_id=thread_id, thread_type=thread_type, ts=ts, - msg=m, + msg=delta, ) elif d.get("deltaMessageReply"): @@ -2723,7 +2703,7 @@ class Client(object): thread_type=thread_type, ts=message.timestamp, metadata=metadata, - msg=m, + msg=delta, ) # New message @@ -2744,117 +2724,78 @@ class Client(object): thread_type=thread_type, ts=ts, metadata=metadata, + msg=delta, + ) + + # Unknown message type + else: + self.onUnknownMesssageType(msg=delta) + + def _parse_payload(self, topic, m): + # Things that directly change chat + if topic == "/t_ms": + if "deltas" not in m: + return + for delta in m["deltas"]: + self._parseDelta(delta) + + # TODO: Remove old parsing below + + # Inbox + elif topic == "inbox": + self.onInbox( + unseen=m["unseen"], + unread=m["unread"], + recent_unread=m["recent_unread"], msg=m, ) + # Typing + # /thread_typing {'sender_fbid': X, 'state': 1, 'type': 'typ', 'thread': 'Y'} + # /orca_typing_notifications {'type': 'typ', 'sender_fbid': X, 'state': 0} + elif topic in ("/thread_typing", "/orca_typing_notifications"): + author_id = str(m["sender_fbid"]) + thread_id = m.get("thread", author_id) + typing_status = TypingStatus(m.get("state")) + thread_type = ( + ThreadType.USER if thread_id == author_id else ThreadType.GROUP + ) + self.onTyping( + author_id=author_id, + status=typing_status, + thread_id=thread_id, + thread_type=thread_type, + msg=m, + ) + + elif topic == "jewel_requests_add": + from_id = m["from"] + self.onFriendRequest(from_id=from_id, msg=m) + + # Chat timestamp / Buddylist overlay + elif topic == "/orca_presence": + if m["list_type"] == "full": + self._buddylist = {} # Refresh internal list + + statuses = dict() + for data in m["list"]: + user_id = str(data["u"]) + 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.onBuddylistOverlay(statuses=statuses, msg=m) + # Unknown message type else: self.onUnknownMesssageType(msg=m) - def _parseMessage(self, content): - """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 - if mtype == "delta": - self._parseDelta(m) - # Inbox - elif mtype == "inbox": - self.onInbox( - unseen=m["unseen"], - unread=m["unread"], - recent_unread=m["recent_unread"], - msg=m, - ) - - # Typing - elif mtype == "typ" or mtype == "ttyp": - author_id = str(m.get("from")) - thread_id = m.get("thread_fbid") - if thread_id: - thread_type = ThreadType.GROUP - thread_id = str(thread_id) - else: - thread_type = ThreadType.USER - if author_id == self._uid: - thread_id = m.get("to") - else: - thread_id = author_id - typing_status = TypingStatus(m.get("st")) - self.onTyping( - author_id=author_id, - status=typing_status, - thread_id=thread_id, - thread_type=thread_type, - msg=m, - ) - - # Delivered - - # Seen - # elif mtype == "m_read_receipt": - # - # self.onSeen(m.get('realtime_viewer_fbid'), m.get('reader'), m.get('time')) - - elif mtype in ["jewel_requests_add"]: - from_id = m["from"] - self.onFriendRequest(from_id=from_id, msg=m) - - # Happens on every login - elif mtype == "qprimer": - self.onQprimer(ts=m.get("made"), msg=m) - - # Is sent before any other message - elif mtype == "deltaflow": - pass - - # Chat timestamp - elif mtype == "chatproxy-presence": - statuses = dict() - for id_, data in m.get("buddyList", {}).items(): - statuses[id_] = ActiveStatus._from_chatproxy_presence(id_, data) - self._buddylist[id_] = statuses[id_] - - 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) - - # Unknown message type - else: - self.onUnknownMesssageType(msg=m) - - except Exception as e: - self.onMessageError(exception=e, msg=m) + def _parse_message(self, topic, data): + try: + self._parse_payload(topic, data) + except Exception as e: + self.onMessageError(exception=e, msg=data) def startListening(self): """Start listening from an external event loop. @@ -2862,6 +2803,15 @@ class Client(object): Raises: 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 def doOneListen(self, markAlive=None): @@ -2879,36 +2829,23 @@ class Client(object): """ if markAlive is not None: 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=self.onListenError) def stopListening(self): - """Clean up the variables from `Client.startListening`.""" + """Stop the listening loop.""" 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): """Initialize and runs the listening loop continually. @@ -3367,6 +3304,17 @@ class Client(object): """ 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): """ Todo: @@ -3865,7 +3813,6 @@ class Client(object): statuses (dict): Dictionary with user IDs as keys and :class:`ActiveStatus` as values msg: A full set of the data received """ - log.debug("Buddylist overlay received: {}".format(statuses)) def onUnknownMesssageType(self, msg=None): """Called when the client is listening, and some unknown data was received. diff --git a/fbchat/_mqtt.py b/fbchat/_mqtt.py new file mode 100644 index 0000000..3769ac8 --- /dev/null +++ b/fbchat/_mqtt.py @@ -0,0 +1,305 @@ +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) + # mqtt.max_queued_messages_set(0) # unlimited + # mqtt.message_retry_set(5) + # 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) + except _exception.FBchatFacebookError: + 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): + # 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, self._HOST), + "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: + err = paho.mqtt.client.error_string(rc) + + # If known/expected error + if rc in [paho.mqtt.client.MQTT_ERR_CONN_LOST]: + log.warning(err) + else: + log.warning("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, + ): + log.debug("MQTT reconnection failed") + + 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) diff --git a/fbchat/_user.py b/fbchat/_user.py index eae5fdb..e7ed86e 100644 --- a/fbchat/_user.py +++ b/fbchat/_user.py @@ -192,17 +192,6 @@ class ActiveStatus(object): in_game = attr.ib(None) @classmethod - def _from_chatproxy_presence(cls, id_, data): - return cls( - active=data["p"] in [2, 3] if "p" in data else None, - last_active=data.get("lat"), - in_game=int(id_) in data.get("gamers", {}), - ) - - @classmethod - def _from_buddylist_overlay(cls, data, in_game=None): - return cls( - active=data["a"] in [2, 3] if "a" in data else None, - last_active=data.get("la"), - in_game=None, - ) + def _from_orca_presence(cls, data): + # TODO: Handle `c` and `vc` keys (Probably some binary data) + return cls(active=data["p"] in [2, 3], last_active=data.get("l"), in_game=None) diff --git a/fbchat/_util.py b/fbchat/_util.py index af06d42..6228989 100644 --- a/fbchat/_util.py +++ b/fbchat/_util.py @@ -57,6 +57,11 @@ def now(): return int(time() * 1000) +def json_minimal(data): + """Get JSON data in minimal form.""" + return json.dumps(data, separators=(",", ":")) + + def strip_json_cruft(text): """Removes `for(;;);` (and other cruft) that preceeds JSON responses.""" try: @@ -65,6 +70,13 @@ def strip_json_cruft(text): raise FBchatException("No JSON object found: {!r}".format(text)) +def get_cookie_header(session, host): + """Extract a cookie header from a requests session.""" + return requests.cookies.get_cookie_header( + session.cookies, requests.Request("GET", host), + ) + + def get_decoded_r(r): return get_decoded(r._content) diff --git a/pyproject.toml b/pyproject.toml index 136268d..29cbc28 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ requires = [ "attrs>=18.2", "requests~=2.19", "beautifulsoup4~=4.0", + "paho-mqtt~=1.5", ] description-file = "README.rst" classifiers = [