conn.py 41 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034
  1. # mautrix-instagram - A Matrix-Instagram puppeting bridge.
  2. # Copyright (C) 2022 Tulir Asokan
  3. #
  4. # This program is free software: you can redistribute it and/or modify
  5. # it under the terms of the GNU Affero General Public License as published by
  6. # the Free Software Foundation, either version 3 of the License, or
  7. # (at your option) any later version.
  8. #
  9. # This program is distributed in the hope that it will be useful,
  10. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  11. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  12. # GNU Affero General Public License for more details.
  13. #
  14. # You should have received a copy of the GNU Affero General Public License
  15. # along with this program. If not, see <https://www.gnu.org/licenses/>.
  16. from __future__ import annotations
  17. from typing import Any, Awaitable, Callable, Iterable, Type, TypeVar
  18. from collections import defaultdict
  19. from socket import error as SocketError, socket
  20. import asyncio
  21. import json
  22. import logging
  23. import re
  24. import time
  25. import zlib
  26. from yarl import URL
  27. import paho.mqtt.client as pmc
  28. from mautrix.util import background_task
  29. from mautrix.util.logging import TraceLogger
  30. from mautrix.util.proxy import ProxyHandler, proxy_with_retry
  31. from ..errors import (
  32. IrisSubscribeError,
  33. MQTTConnectionUnauthorized,
  34. MQTTNotConnected,
  35. MQTTNotLoggedIn,
  36. MQTTReconnectionError,
  37. )
  38. from ..state import AndroidState
  39. from ..types import (
  40. AppPresenceEventPayload,
  41. ClientConfigUpdatePayload,
  42. CommandResponse,
  43. IrisPayload,
  44. IrisPayloadData,
  45. LiveVideoCommentPayload,
  46. MessageSyncEvent,
  47. MessageSyncMessage,
  48. Operation,
  49. PubsubEvent,
  50. PubsubPayload,
  51. ReactionStatus,
  52. ReactionType,
  53. RealtimeDirectEvent,
  54. RealtimeZeroProvisionPayload,
  55. ThreadAction,
  56. ThreadItemType,
  57. ThreadRemoveEvent,
  58. ThreadSyncEvent,
  59. TypingStatus,
  60. )
  61. from .events import Connect, Disconnect, NewSequenceID, ProxyUpdate
  62. from .otclient import MQTToTClient
  63. from .subscription import GraphQLQueryID, RealtimeTopic, everclear_subscriptions
  64. from .thrift import ForegroundStateConfig, IncomingMessage, RealtimeClientInfo, RealtimeConfig
  65. try:
  66. import socks
  67. except ImportError:
  68. socks = None
  69. T = TypeVar("T")
  70. ACTIVITY_INDICATOR_REGEX = re.compile(
  71. r"/direct_v2/threads/([\w_]+)/activity_indicator_id/([\w_]+)"
  72. )
  73. INBOX_THREAD_REGEX = re.compile(r"/direct_v2/inbox/threads/([\w_]+)")
  74. REQUEST_TIMEOUT = 60 * 3
  75. REQUEST_KEEPALIVE = 5
  76. class AndroidMQTT:
  77. _loop: asyncio.AbstractEventLoop
  78. _client: MQTToTClient
  79. log: TraceLogger
  80. state: AndroidState
  81. _graphql_subs: set[str]
  82. _skywalker_subs: set[str]
  83. _iris_seq_id: int | None
  84. _iris_snapshot_at_ms: int | None
  85. _publish_waiters: dict[int, asyncio.Future]
  86. _response_waiters: dict[RealtimeTopic, asyncio.Future]
  87. _response_waiter_locks: dict[RealtimeTopic, asyncio.Lock]
  88. _message_response_waiter_lock: asyncio.Lock
  89. _message_response_waiter_id: str | None
  90. _message_response_waiter: asyncio.Future | None
  91. _disconnect_error: Exception | None
  92. _event_handlers: dict[Type[T], list[Callable[[T], Awaitable[None]]]]
  93. _outgoing_events: asyncio.Queue
  94. _event_dispatcher_task: asyncio.Task | None
  95. # region Initialization
  96. def __init__(
  97. self,
  98. state: AndroidState,
  99. log: TraceLogger | None = None,
  100. proxy_handler: ProxyHandler | None = None,
  101. mqtt_keepalive: int = 60,
  102. ) -> None:
  103. self._graphql_subs = set()
  104. self._skywalker_subs = set()
  105. self._iris_seq_id = None
  106. self._iris_snapshot_at_ms = None
  107. self._publish_waiters = {}
  108. self._response_waiters = {}
  109. self._message_response_waiter_lock = asyncio.Lock()
  110. self._message_response_waiter_id = None
  111. self._message_response_waiter = None
  112. self._disconnect_error = None
  113. self._response_waiter_locks = defaultdict(lambda: asyncio.Lock())
  114. self._event_handlers = defaultdict(lambda: [])
  115. self._event_dispatcher_task = None
  116. self._outgoing_events = asyncio.Queue()
  117. self.log = log or logging.getLogger("mauigpapi.mqtt")
  118. self._loop = asyncio.get_running_loop()
  119. self.state = state
  120. self._client = MQTToTClient(
  121. client_id=self._form_client_id(),
  122. clean_session=True,
  123. protocol=pmc.MQTTv31,
  124. transport="tcp",
  125. )
  126. self.proxy_handler = proxy_handler
  127. self.setup_proxy()
  128. self._client.enable_logger()
  129. self._client.tls_set()
  130. # mqtt.max_inflight_messages_set(20) # The rest will get queued
  131. # mqtt.max_queued_messages_set(0) # Unlimited messages can be queued
  132. # mqtt.message_retry_set(20) # Retry sending for at least 20 seconds
  133. # mqtt.reconnect_delay_set(min_delay=1, max_delay=120)
  134. self._default_keepalive = mqtt_keepalive
  135. self._client.connect_async("edge-mqtt.facebook.com", 443, keepalive=mqtt_keepalive)
  136. self._client.on_message = self._on_message_handler
  137. self._client.on_publish = self._on_publish_handler
  138. self._client.on_connect = self._on_connect_handler
  139. self._client.on_disconnect = self._on_disconnect_handler
  140. self._client.on_socket_open = self._on_socket_open
  141. self._client.on_socket_close = self._on_socket_close
  142. self._client.on_socket_register_write = self._on_socket_register_write
  143. self._client.on_socket_unregister_write = self._on_socket_unregister_write
  144. def setup_proxy(self):
  145. http_proxy = self.proxy_handler.get_proxy_url() if self.proxy_handler else None
  146. if http_proxy:
  147. if not socks:
  148. self.log.warning("http_proxy is set, but pysocks is not installed")
  149. else:
  150. proxy_url = URL(http_proxy)
  151. proxy_type = {
  152. "http": socks.HTTP,
  153. "https": socks.HTTP,
  154. "socks": socks.SOCKS5,
  155. "socks5": socks.SOCKS5,
  156. "socks4": socks.SOCKS4,
  157. }[proxy_url.scheme]
  158. self._client.proxy_set(
  159. proxy_type=proxy_type,
  160. proxy_addr=proxy_url.host,
  161. proxy_port=proxy_url.port,
  162. proxy_username=proxy_url.user,
  163. proxy_password=proxy_url.password,
  164. )
  165. def _clear_publish_waiters(self) -> None:
  166. for waiter in self._publish_waiters.values():
  167. if not waiter.done():
  168. waiter.set_exception(MQTTNotConnected("MQTT disconnected before PUBACK received"))
  169. self._publish_waiters = {}
  170. def _form_client_id(self) -> bytes:
  171. subscribe_topics = [
  172. RealtimeTopic.PUBSUB, # 88
  173. RealtimeTopic.SUB_IRIS_RESPONSE, # 135
  174. RealtimeTopic.REALTIME_SUB, # 149
  175. RealtimeTopic.REGION_HINT, # 150
  176. RealtimeTopic.T_RTC_LOG, # 274
  177. RealtimeTopic.SEND_MESSAGE_RESPONSE, # 133
  178. RealtimeTopic.LARGE_SCALE_FIRE_AND_FORGET_SYNC, # 279
  179. RealtimeTopic.MESSAGE_SYNC, # 146
  180. RealtimeTopic.LIGHTSPEED_RESPONSE, # 179
  181. RealtimeTopic.UNKNOWN_PP, # 34
  182. ]
  183. subscribe_topic_ids = [int(topic.encoded) for topic in subscribe_topics]
  184. password = f"authorization={self.state.session.authorization}"
  185. cfg = RealtimeConfig(
  186. client_identifier=self.state.device.phone_id[:20],
  187. client_info=RealtimeClientInfo(
  188. user_id=int(self.state.user_id),
  189. user_agent=self.state.user_agent,
  190. client_capabilities=0b10110111,
  191. endpoint_capabilities=0,
  192. publish_format=1,
  193. no_automatic_foreground=True,
  194. make_user_available_in_foreground=False,
  195. device_id=self.state.device.phone_id,
  196. is_initially_foreground=False,
  197. network_type=1,
  198. network_subtype=-1,
  199. client_mqtt_session_id=int(time.time() * 1000) & 0xFFFFFFFF,
  200. subscribe_topics=subscribe_topic_ids,
  201. client_type="cookie_auth",
  202. app_id=567067343352427,
  203. region_preference=self.state.session.region_hint or "LLA",
  204. device_secret="",
  205. client_stack=3,
  206. ),
  207. password=password,
  208. app_specific_info={
  209. "capabilities": self.state.application.CAPABILITIES,
  210. "app_version": self.state.application.APP_VERSION,
  211. "everclear_subscriptions": json.dumps(everclear_subscriptions),
  212. "User-Agent": self.state.user_agent,
  213. "Accept-Language": self.state.device.language.replace("_", "-"),
  214. "platform": "android",
  215. "ig_mqtt_route": "django",
  216. "pubsub_msg_type_blacklist": "direct, typing_type",
  217. # "auth_cache_enabled": "1",
  218. },
  219. )
  220. return zlib.compress(cfg.to_thrift(), level=9)
  221. # endregion
  222. def _on_socket_open(self, client: MQTToTClient, _: Any, sock: socket) -> None:
  223. self._loop.add_reader(sock, client.loop_read)
  224. def _on_socket_close(self, client: MQTToTClient, _: Any, sock: socket) -> None:
  225. self._loop.remove_reader(sock)
  226. def _on_socket_register_write(self, client: MQTToTClient, _: Any, sock: socket) -> None:
  227. self._loop.add_writer(sock, client.loop_write)
  228. def _on_socket_unregister_write(self, client: MQTToTClient, _: Any, sock: socket) -> None:
  229. self._loop.remove_writer(sock)
  230. def _on_connect_handler(
  231. self, client: MQTToTClient, _: Any, flags: dict[str, Any], rc: int
  232. ) -> None:
  233. if rc != 0:
  234. err = pmc.connack_string(rc)
  235. self.log.error("MQTT Connection Error: %s (%d)", err, rc)
  236. if rc == pmc.CONNACK_REFUSED_NOT_AUTHORIZED:
  237. self._disconnect_error = MQTTConnectionUnauthorized()
  238. self.disconnect()
  239. return
  240. self._loop.create_task(self._post_connect())
  241. def _on_disconnect_handler(self, client: MQTToTClient, _: Any, rc: int) -> None:
  242. err_str = "Generic error." if rc == pmc.MQTT_ERR_NOMEM else pmc.error_string(rc)
  243. self.log.debug("MQTT disconnection code %d: %s", rc, err_str)
  244. self._clear_publish_waiters()
  245. async def _post_connect(self) -> None:
  246. await self._dispatch(Connect())
  247. self.log.debug("Re-subscribing to things after connect")
  248. if self._graphql_subs:
  249. res = await self.graphql_subscribe(self._graphql_subs)
  250. self.log.trace("GraphQL subscribe response: %s", res)
  251. if self._skywalker_subs:
  252. res = await self.skywalker_subscribe(self._skywalker_subs)
  253. self.log.trace("Skywalker subscribe response: %s", res)
  254. if self._iris_seq_id:
  255. retry = 0
  256. while True:
  257. try:
  258. await self.iris_subscribe(self._iris_seq_id, self._iris_snapshot_at_ms)
  259. break
  260. except (asyncio.TimeoutError, IrisSubscribeError) as e:
  261. self.log.exception("Error requesting iris subscribe")
  262. retry += 1
  263. if retry >= 5 or isinstance(e, IrisSubscribeError):
  264. self._disconnect_error = e
  265. self.disconnect()
  266. break
  267. await asyncio.sleep(5)
  268. self.log.debug("Retrying iris subscribe")
  269. def _on_publish_handler(self, client: MQTToTClient, _: Any, mid: int) -> None:
  270. try:
  271. waiter = self._publish_waiters.pop(mid)
  272. except KeyError:
  273. self.log.trace(f"Got publish confirmation for {mid}, but no waiters")
  274. return
  275. self.log.trace(f"Got publish confirmation for {mid}")
  276. if not waiter.done():
  277. waiter.set_result(None)
  278. self.maybe_reset_keepalive()
  279. # region Incoming event parsing
  280. def _parse_direct_thread_path(self, path: str) -> dict:
  281. try:
  282. blank, direct_v2, threads, thread_id, *rest = path.split("/")
  283. except (ValueError, IndexError) as e:
  284. self.log.debug(f"Got {e!r} while parsing path {path}")
  285. raise
  286. if (blank, direct_v2, threads) != ("", "direct_v2", "threads"):
  287. self.log.debug(f"Got unexpected first parts in direct thread path {path}")
  288. raise ValueError("unexpected first three parts in _parse_direct_thread_path")
  289. additional = {"thread_id": thread_id}
  290. if rest:
  291. subitem_key = rest[0]
  292. if subitem_key == "approval_required_for_new_members":
  293. additional["approval_required_for_new_members"] = True
  294. elif subitem_key == "thread_image":
  295. additional["is_thread_image"] = True
  296. elif subitem_key == "participants" and len(rest) > 2 and rest[2] == "has_seen":
  297. additional["has_seen"] = int(rest[1])
  298. elif subitem_key == "items":
  299. additional["item_id"] = rest[1]
  300. if len(rest) > 4 and rest[2] == "reactions":
  301. additional["reaction_type"] = ReactionType(rest[3])
  302. additional["reaction_user_id"] = int(rest[4])
  303. elif subitem_key in "admin_user_ids":
  304. additional["admin_user_id"] = int(rest[1])
  305. elif subitem_key == "activity_indicator_id":
  306. additional["activity_indicator_id"] = rest[1]
  307. self.log.trace("Parsed path %s -> %s", path, additional)
  308. return additional
  309. def _on_messager_sync_item(self, part: IrisPayloadData, parsed_item: IrisPayload) -> bool:
  310. if part.path.startswith("/direct_v2/threads/"):
  311. raw_message = {
  312. "path": part.path,
  313. "op": part.op,
  314. **self._parse_direct_thread_path(part.path),
  315. }
  316. try:
  317. json_value = json.loads(part.value)
  318. if "reaction_type" in raw_message:
  319. self.log.trace("Treating %s as new reaction data", json_value)
  320. raw_message["new_reaction"] = json_value
  321. json_value["sender_id"] = raw_message.pop("reaction_user_id")
  322. json_value["type"] = raw_message.pop("reaction_type")
  323. json_value["client_context"] = parsed_item.mutation_token
  324. if part.op == Operation.REMOVE:
  325. json_value["emoji"] = None
  326. json_value["timestamp"] = None
  327. else:
  328. raw_message = {
  329. **raw_message,
  330. **json_value,
  331. }
  332. except (json.JSONDecodeError, TypeError):
  333. raw_message["value"] = part.value
  334. message = MessageSyncMessage.deserialize(raw_message)
  335. evt = MessageSyncEvent(iris=parsed_item, message=message)
  336. elif part.path.startswith("/direct_v2/inbox/threads/"):
  337. if part.op == Operation.REMOVE:
  338. blank, direct_v2, inbox, threads, thread_id, *_ = part.path.split("/")
  339. evt = ThreadRemoveEvent.deserialize(
  340. {
  341. "thread_id": thread_id,
  342. "path": part.path,
  343. "op": part.op,
  344. **json.loads(part.value),
  345. }
  346. )
  347. else:
  348. evt = ThreadSyncEvent.deserialize(
  349. {
  350. "path": part.path,
  351. "op": part.op,
  352. **json.loads(part.value),
  353. }
  354. )
  355. else:
  356. self.log.warning(f"Unsupported path {part.path}")
  357. return False
  358. self._outgoing_events.put_nowait(evt)
  359. return True
  360. def _on_message_sync(self, payload: bytes) -> None:
  361. parsed = json.loads(payload.decode("utf-8"))
  362. self.log.trace("Got message sync event: %s", parsed)
  363. has_items = False
  364. for sync_item in parsed:
  365. parsed_item = IrisPayload.deserialize(sync_item)
  366. if self._iris_seq_id < parsed_item.seq_id:
  367. self.log.trace(f"Got new seq_id: {parsed_item.seq_id}")
  368. self._iris_seq_id = parsed_item.seq_id
  369. self._iris_snapshot_at_ms = int(time.time() * 1000)
  370. background_task.create(
  371. self._dispatch(NewSequenceID(self._iris_seq_id, self._iris_snapshot_at_ms))
  372. )
  373. for part in parsed_item.data:
  374. has_items = self._on_messager_sync_item(part, parsed_item) or has_items
  375. if has_items and not self._event_dispatcher_task:
  376. self._event_dispatcher_task = asyncio.create_task(self._dispatcher_loop())
  377. def _on_pubsub(self, payload: bytes) -> None:
  378. parsed_thrift = IncomingMessage.from_thrift(payload)
  379. self.log.trace(f"Got pubsub event {parsed_thrift.topic} / {parsed_thrift.payload}")
  380. message = PubsubPayload.parse_json(parsed_thrift.payload)
  381. for data in message.data:
  382. match = ACTIVITY_INDICATOR_REGEX.match(data.path)
  383. if match:
  384. evt = PubsubEvent(
  385. data=data,
  386. base=message,
  387. thread_id=match.group(1),
  388. activity_indicator_id=match.group(2),
  389. )
  390. self._loop.create_task(self._dispatch(evt))
  391. elif not data.double_publish:
  392. self.log.debug("Pubsub: no activity indicator on data: %s", data)
  393. else:
  394. self.log.debug("Pubsub: double publish: %s", data.path)
  395. def _parse_realtime_sub_item(self, topic: str | GraphQLQueryID, raw: dict) -> Iterable[Any]:
  396. if topic == GraphQLQueryID.APP_PRESENCE:
  397. yield AppPresenceEventPayload.deserialize(raw).presence_event
  398. elif topic == GraphQLQueryID.ZERO_PROVISION:
  399. yield RealtimeZeroProvisionPayload.deserialize(raw).zero_product_provisioning_event
  400. elif topic == GraphQLQueryID.CLIENT_CONFIG_UPDATE:
  401. yield ClientConfigUpdatePayload.deserialize(raw).client_config_update_event
  402. elif topic == GraphQLQueryID.LIVE_REALTIME_COMMENTS:
  403. yield LiveVideoCommentPayload.deserialize(raw).live_video_comment_event
  404. elif topic == "direct":
  405. event = raw["event"]
  406. for item in raw["data"]:
  407. yield RealtimeDirectEvent.deserialize(
  408. {
  409. "event": event,
  410. **self._parse_direct_thread_path(item["path"]),
  411. **item,
  412. }
  413. )
  414. def _on_realtime_sub(self, payload: bytes) -> None:
  415. parsed_thrift = IncomingMessage.from_thrift(payload)
  416. try:
  417. topic = GraphQLQueryID(parsed_thrift.topic)
  418. except ValueError:
  419. topic = parsed_thrift.topic
  420. self.log.trace(f"Got realtime sub event {topic} / {parsed_thrift.payload}")
  421. allowed = (
  422. "direct",
  423. GraphQLQueryID.APP_PRESENCE,
  424. GraphQLQueryID.ZERO_PROVISION,
  425. GraphQLQueryID.CLIENT_CONFIG_UPDATE,
  426. GraphQLQueryID.LIVE_REALTIME_COMMENTS,
  427. )
  428. if topic not in allowed:
  429. return
  430. parsed_json = json.loads(parsed_thrift.payload)
  431. for evt in self._parse_realtime_sub_item(topic, parsed_json):
  432. self._loop.create_task(self._dispatch(evt))
  433. def _handle_send_response(self, message: pmc.MQTTMessage) -> None:
  434. data = json.loads(message.payload.decode("utf-8"))
  435. try:
  436. ccid = data["payload"]["client_context"]
  437. except KeyError:
  438. self.log.warning(
  439. "Didn't find client_context in send message response: %s", message.payload
  440. )
  441. ccid = self._message_response_waiter_id
  442. else:
  443. if ccid != self._message_response_waiter_id:
  444. self.log.error(
  445. "Mismatching client_context in send message response (%s != %s)",
  446. ccid,
  447. self._message_response_waiter_id,
  448. )
  449. return
  450. if self._message_response_waiter and not self._message_response_waiter.done():
  451. self.log.debug("Got response to %s: %s", ccid, message.payload)
  452. self._message_response_waiter.set_result(message)
  453. self._message_response_waiter = None
  454. self._message_response_waiter_id = None
  455. else:
  456. self.log.warning("Didn't find task waiting for response %s", message.payload)
  457. def _on_message_handler(self, client: MQTToTClient, _: Any, message: pmc.MQTTMessage) -> None:
  458. try:
  459. topic = RealtimeTopic.decode(message.topic)
  460. # Instagram Android MQTT messages are always compressed
  461. message.payload = zlib.decompress(message.payload)
  462. if topic == RealtimeTopic.MESSAGE_SYNC:
  463. self._on_message_sync(message.payload)
  464. elif topic == RealtimeTopic.PUBSUB:
  465. self._on_pubsub(message.payload)
  466. elif topic == RealtimeTopic.REALTIME_SUB:
  467. self._on_realtime_sub(message.payload)
  468. elif topic == RealtimeTopic.SEND_MESSAGE_RESPONSE:
  469. self._handle_send_response(message)
  470. elif topic == RealtimeTopic.UNKNOWN_PP:
  471. self.log.warning("Reconnecting after receiving /pp message")
  472. background_task.create(self._reconnect())
  473. else:
  474. try:
  475. waiter = self._response_waiters.pop(topic)
  476. except KeyError:
  477. self.log.debug(
  478. "No handler for MQTT message in %s: %s", topic.value, message.payload
  479. )
  480. else:
  481. if not waiter.done():
  482. waiter.set_result(message)
  483. self.log.trace("Got response %s: %s", topic.value, message.payload)
  484. else:
  485. self.log.debug(
  486. "Got response in %s, but waiter was already cancelled: %s",
  487. topic,
  488. message.payload,
  489. )
  490. except Exception:
  491. self.log.exception("Error in incoming MQTT message handler")
  492. self.log.trace("Errored MQTT payload: %s", message.payload)
  493. finally:
  494. self.maybe_reset_keepalive()
  495. # endregion
  496. async def _reconnect(self) -> None:
  497. try:
  498. self._client.reconnect()
  499. return
  500. except (SocketError, OSError, pmc.WebsocketConnectionError) as e:
  501. self.log.exception("Error reconnecting to MQTT")
  502. raise MQTTReconnectionError("MQTT reconnection failed") from e
  503. def add_event_handler(
  504. self, evt_type: Type[T], handler: Callable[[T], Awaitable[None]]
  505. ) -> None:
  506. self._event_handlers[evt_type].append(handler)
  507. async def _dispatch(self, evt: T) -> None:
  508. for handler in self._event_handlers[type(evt)]:
  509. try:
  510. await handler(evt)
  511. except Exception:
  512. self.log.exception(f"Error in {type(evt).__name__} handler")
  513. def disconnect(self) -> None:
  514. self._client.disconnect()
  515. async def _dispatcher_loop(self) -> None:
  516. loop_id = f"{hex(id(self))}#{time.monotonic()}"
  517. self.log.debug(f"Dispatcher loop {loop_id} starting")
  518. try:
  519. while True:
  520. evt = await self._outgoing_events.get()
  521. await asyncio.shield(self._dispatch(evt))
  522. except asyncio.CancelledError:
  523. tasks = self._outgoing_events
  524. self._outgoing_events = asyncio.Queue()
  525. if not tasks.empty():
  526. self.log.debug(
  527. f"Dispatcher loop {loop_id} stopping after dispatching {tasks.qsize()} events"
  528. )
  529. while not tasks.empty():
  530. await self._dispatch(tasks.get_nowait())
  531. raise
  532. finally:
  533. self.log.debug(f"Dispatcher loop {loop_id} stopped")
  534. async def listen(
  535. self,
  536. graphql_subs: set[str] | None = None,
  537. skywalker_subs: set[str] | None = None,
  538. seq_id: int = None,
  539. snapshot_at_ms: int = None,
  540. retry_limit: int = 10,
  541. ) -> None:
  542. self._graphql_subs = graphql_subs or set()
  543. self._skywalker_subs = skywalker_subs or set()
  544. self._iris_seq_id = seq_id
  545. self._iris_snapshot_at_ms = snapshot_at_ms
  546. self.log.debug("Connecting to Instagram MQTT")
  547. async def connect_and_watch():
  548. await self._reconnect()
  549. while True:
  550. try:
  551. await asyncio.sleep(1)
  552. except asyncio.CancelledError:
  553. self.disconnect()
  554. # this might not be necessary
  555. self._client.loop_misc()
  556. return
  557. rc = self._client.loop_misc()
  558. # If disconnect() has been called
  559. # Beware, internal API, may have to change this to something more stable!
  560. if self._client._state == pmc.mqtt_cs_disconnecting:
  561. return # Stop listening
  562. if rc != pmc.MQTT_ERR_SUCCESS:
  563. # If known/expected error
  564. if rc == pmc.MQTT_ERR_CONN_LOST:
  565. await self._dispatch(Disconnect(reason="Connection lost, retrying"))
  566. raise MQTTNotConnected("MQTT_ERR_CONN_LOST")
  567. elif rc == pmc.MQTT_ERR_NOMEM:
  568. # This error is wrongly classified
  569. # See https://github.com/eclipse/paho.mqtt.python/issues/340
  570. await self._dispatch(Disconnect(reason="Connection lost, retrying"))
  571. raise MQTTNotConnected("MQTT_ERR_NOMEM")
  572. elif rc == pmc.MQTT_ERR_CONN_REFUSED:
  573. await self._dispatch(Disconnect(reason="Connection refused, retrying"))
  574. raise MQTTNotLoggedIn("MQTT_ERR_CONN_REFUSED")
  575. elif rc == pmc.MQTT_ERR_NO_CONN:
  576. await self._dispatch(Disconnect(reason="Connection dropped, retrying"))
  577. raise MQTTNotConnected("MQTT_ERR_NO_CONN")
  578. else:
  579. err = pmc.error_string(rc)
  580. self.log.error("MQTT Error: %s", err)
  581. await self._dispatch(Disconnect(reason=f"MQTT Error: {err}, retrying"))
  582. raise MQTTNotConnected(err)
  583. await proxy_with_retry(
  584. "mqtt.listen",
  585. lambda: connect_and_watch(),
  586. logger=self.log,
  587. proxy_handler=self.proxy_handler,
  588. on_proxy_change=lambda: self._dispatch(ProxyUpdate()),
  589. max_retries=retry_limit,
  590. retryable_exceptions=(MQTTNotConnected, MQTTReconnectionError),
  591. # Wait 1s * errors, max 5s for fast reconnect or die
  592. max_wait_seconds=5,
  593. multiply_wait_seconds=1,
  594. # If connection stable for >1h, reset the error counter
  595. reset_after_seconds=3600,
  596. )
  597. if self._event_dispatcher_task:
  598. self._event_dispatcher_task.cancel()
  599. self._event_dispatcher_task = None
  600. if self._disconnect_error:
  601. self.log.info("disconnect_error is set, raising and clearing variable")
  602. err = self._disconnect_error
  603. self._disconnect_error = None
  604. raise err
  605. # region Basic outgoing MQTT
  606. @staticmethod
  607. def _publish_cancel_later(fut: asyncio.Future) -> None:
  608. if not fut.done():
  609. fut.set_exception(asyncio.TimeoutError("MQTT publish timed out"))
  610. @staticmethod
  611. def _request_cancel_later(fut: asyncio.Future) -> None:
  612. if not fut.done():
  613. fut.set_exception(asyncio.TimeoutError("MQTT request timed out"))
  614. # The following two functions mutate the client keepalive (cheeky) to temporarily increase
  615. # ping attempts during read/write to MQTT. If things are flowing this should change nothing,
  616. # as pings only send when idle. It should, however, allow the client to detect a bad MQTT
  617. # connection much quicker than the default keepalive.
  618. def set_request_keepalive(self):
  619. self._client._keepalive = REQUEST_KEEPALIVE
  620. def maybe_reset_keepalive(self):
  621. # Reset the keepalive back to the default value if we have no pending publish/receive
  622. if (
  623. not self._response_waiters
  624. and not self._publish_waiters
  625. and not self._message_response_waiter
  626. ):
  627. self._client._keepalive = self._default_keepalive
  628. def publish(self, topic: RealtimeTopic, payload: str | bytes | dict) -> asyncio.Future:
  629. if isinstance(payload, dict):
  630. payload = json.dumps(payload)
  631. if isinstance(payload, str):
  632. payload = payload.encode("utf-8")
  633. self.log.trace(f"Publishing message in {topic.value} ({topic.encoded}): {payload}")
  634. payload = zlib.compress(payload, level=9)
  635. self.set_request_keepalive()
  636. info = self._client.publish(topic.encoded, payload, qos=1)
  637. self.log.trace(f"Published message ID: {info.mid}")
  638. fut = self._loop.create_future()
  639. timeout_handle = self._loop.call_later(REQUEST_TIMEOUT, self._publish_cancel_later, fut)
  640. fut.add_done_callback(lambda _: timeout_handle.cancel())
  641. self._publish_waiters[info.mid] = fut
  642. return fut
  643. async def request(
  644. self,
  645. topic: RealtimeTopic,
  646. response: RealtimeTopic,
  647. payload: str | bytes | dict,
  648. timeout: int | None = None,
  649. ) -> pmc.MQTTMessage:
  650. async with self._response_waiter_locks[response]:
  651. fut = self._loop.create_future()
  652. self._response_waiters[response] = fut
  653. background_task.create(self.publish(topic, payload))
  654. self.log.trace(
  655. f"Request publish to {topic.value} queued, waiting for response {response.name}"
  656. )
  657. timeout_handle = self._loop.call_later(
  658. timeout or REQUEST_TIMEOUT, self._request_cancel_later, fut
  659. )
  660. fut.add_done_callback(lambda _: timeout_handle.cancel())
  661. return await fut
  662. async def iris_subscribe(self, seq_id: int, snapshot_at_ms: int) -> None:
  663. self.log.debug(f"Requesting iris subscribe {seq_id}/{snapshot_at_ms}")
  664. resp = await self.request(
  665. RealtimeTopic.SUB_IRIS,
  666. RealtimeTopic.SUB_IRIS_RESPONSE,
  667. {
  668. "seq_id": seq_id,
  669. "snapshot_at_ms": snapshot_at_ms,
  670. "snapshot_app_version": self.state.application.APP_VERSION,
  671. "timezone_offset": int(self.state.device.timezone_offset),
  672. "subscription_type": "message",
  673. },
  674. timeout=20,
  675. )
  676. self.log.debug("Iris subscribe response: %s", resp.payload.decode("utf-8"))
  677. resp_dict = json.loads(resp.payload.decode("utf-8"))
  678. if resp_dict["error_type"] and resp_dict["error_message"]:
  679. raise IrisSubscribeError(resp_dict["error_type"], resp_dict["error_message"])
  680. latest_seq_id = resp_dict.get("latest_seq_id")
  681. if latest_seq_id > self._iris_seq_id:
  682. self.log.info(f"Latest sequence ID is {latest_seq_id}, catching up from {seq_id}")
  683. self._iris_seq_id = latest_seq_id
  684. self._iris_snapshot_at_ms = resp_dict.get("subscribed_at_ms", int(time.time() * 1000))
  685. background_task.create(
  686. self._dispatch(NewSequenceID(self._iris_seq_id, self._iris_snapshot_at_ms))
  687. )
  688. def graphql_subscribe(self, subs: set[str]) -> asyncio.Future:
  689. self._graphql_subs |= subs
  690. return self.publish(RealtimeTopic.REALTIME_SUB, {"sub": list(subs)})
  691. def graphql_unsubscribe(self, subs: set[str]) -> asyncio.Future:
  692. self._graphql_subs -= subs
  693. return self.publish(RealtimeTopic.REALTIME_SUB, {"unsub": list(subs)})
  694. def skywalker_subscribe(self, subs: set[str]) -> asyncio.Future:
  695. self._skywalker_subs |= subs
  696. return self.publish(RealtimeTopic.PUBSUB, {"sub": list(subs)})
  697. def skywalker_unsubscribe(self, subs: set[str]) -> asyncio.Future:
  698. self._skywalker_subs -= subs
  699. return self.publish(RealtimeTopic.PUBSUB, {"unsub": list(subs)})
  700. # endregion
  701. # region Actually sending messages and stuff
  702. async def send_foreground_state(self, state: ForegroundStateConfig) -> None:
  703. self.log.debug("Updating foreground state: %s", state)
  704. await self.publish(
  705. RealtimeTopic.FOREGROUND_STATE, zlib.compress(state.to_thrift(), level=9)
  706. )
  707. if state.keep_alive_timeout:
  708. self._client._keepalive = state.keep_alive_timeout
  709. async def send_command(
  710. self,
  711. thread_id: str,
  712. action: ThreadAction,
  713. client_context: str | None = None,
  714. **kwargs: Any,
  715. ) -> CommandResponse | None:
  716. self.log.debug(f"Preparing to send {action} to {thread_id} with {client_context}")
  717. client_context = client_context or self.state.gen_client_context()
  718. req = {
  719. "thread_id": thread_id,
  720. "client_context": client_context,
  721. "offline_threading_id": client_context,
  722. "mutation_token": client_context,
  723. "action": action.value,
  724. "is_shh_mode": "0",
  725. "sampled": False,
  726. "session_id": f"UFS-{self.state.pigeon_session_id}-0",
  727. # "device_id": self.state.cookies["ig_did"],
  728. **kwargs,
  729. "btt_dual_send": False,
  730. "is_ae_dual_send": False,
  731. }
  732. lock_start = time.monotonic()
  733. async with self._message_response_waiter_lock:
  734. lock_wait_dur = time.monotonic() - lock_start
  735. if lock_wait_dur > 1:
  736. self.log.warning(f"Waited {lock_wait_dur:.3f} seconds to send {client_context}")
  737. fut = self._message_response_waiter = asyncio.Future()
  738. self._message_response_waiter_id = client_context
  739. background_task.create(self.publish(RealtimeTopic.SEND_MESSAGE, req))
  740. self.log.debug(
  741. f"Request publish to {RealtimeTopic.SEND_MESSAGE} queued, "
  742. f"waiting for response {RealtimeTopic.SEND_MESSAGE_RESPONSE}"
  743. )
  744. try:
  745. resp = await asyncio.wait_for(fut, timeout=REQUEST_TIMEOUT)
  746. except asyncio.TimeoutError:
  747. self.log.error(f"Request with ID {client_context} timed out!")
  748. raise
  749. return CommandResponse.parse_json(resp.payload.decode("utf-8"))
  750. def send_item(
  751. self,
  752. thread_id: str,
  753. item_type: ThreadItemType,
  754. shh_mode: bool = False,
  755. client_context: str | None = None,
  756. **kwargs: Any,
  757. ) -> Awaitable[CommandResponse]:
  758. return self.send_command(
  759. thread_id,
  760. item_type=item_type.value,
  761. is_shh_mode=str(int(shh_mode)),
  762. action=ThreadAction.SEND_ITEM,
  763. client_context=client_context,
  764. **kwargs,
  765. )
  766. def send_hashtag(
  767. self,
  768. thread_id: str,
  769. hashtag: str,
  770. text: str = "",
  771. shh_mode: bool = False,
  772. client_context: str | None = None,
  773. ) -> Awaitable[CommandResponse]:
  774. return self.send_item(
  775. thread_id,
  776. text=text,
  777. item_id=hashtag,
  778. shh_mode=shh_mode,
  779. item_type=ThreadItemType.HASHTAG,
  780. client_context=client_context,
  781. )
  782. def send_like(
  783. self, thread_id: str, shh_mode: bool = False, client_context: str | None = None
  784. ) -> Awaitable[CommandResponse]:
  785. return self.send_item(
  786. thread_id,
  787. shh_mode=shh_mode,
  788. item_type=ThreadItemType.LIKE,
  789. client_context=client_context,
  790. )
  791. def send_location(
  792. self,
  793. thread_id: str,
  794. venue_id: str,
  795. text: str = "",
  796. shh_mode: bool = False,
  797. client_context: str | None = None,
  798. ) -> Awaitable[CommandResponse]:
  799. return self.send_item(
  800. thread_id,
  801. text=text,
  802. item_id=venue_id,
  803. shh_mode=shh_mode,
  804. item_type=ThreadItemType.LOCATION,
  805. client_context=client_context,
  806. )
  807. def send_media(
  808. self,
  809. thread_id: str,
  810. media_id: str,
  811. text: str = "",
  812. shh_mode: bool = False,
  813. client_context: str | None = None,
  814. ) -> Awaitable[CommandResponse]:
  815. return self.send_item(
  816. thread_id,
  817. text=text,
  818. media_id=media_id,
  819. shh_mode=shh_mode,
  820. item_type=ThreadItemType.MEDIA_SHARE,
  821. client_context=client_context,
  822. )
  823. def send_profile(
  824. self,
  825. thread_id: str,
  826. user_id: str,
  827. text: str = "",
  828. shh_mode: bool = False,
  829. client_context: str | None = None,
  830. ) -> Awaitable[CommandResponse]:
  831. return self.send_item(
  832. thread_id,
  833. text=text,
  834. item_id=user_id,
  835. shh_mode=shh_mode,
  836. item_type=ThreadItemType.PROFILE,
  837. client_context=client_context,
  838. )
  839. def send_reaction(
  840. self,
  841. thread_id: str,
  842. emoji: str,
  843. item_id: str,
  844. reaction_status: ReactionStatus = ReactionStatus.CREATED,
  845. target_item_type: ThreadItemType = ThreadItemType.TEXT,
  846. shh_mode: bool = False,
  847. client_context: str | None = None,
  848. original_message_client_context: str | None = None,
  849. ) -> Awaitable[CommandResponse]:
  850. kwargs = (
  851. {
  852. "original_message_client_context": original_message_client_context,
  853. }
  854. if original_message_client_context
  855. else {}
  856. )
  857. return self.send_item(
  858. thread_id,
  859. reaction_status=reaction_status.value,
  860. node_type="item",
  861. reaction_type="like",
  862. target_item_type=target_item_type.value,
  863. emoji=emoji,
  864. item_id=item_id,
  865. reaction_action_source=(
  866. "double_tap" if reaction_status == ReactionStatus.CREATED else "action_menu"
  867. ),
  868. shh_mode=shh_mode,
  869. item_type=ThreadItemType.REACTION,
  870. client_context=client_context,
  871. super_react_type="none",
  872. send_attribution="direct_thread",
  873. **kwargs,
  874. )
  875. def send_user_story(
  876. self,
  877. thread_id: str,
  878. media_id: str,
  879. text: str = "",
  880. shh_mode: bool = False,
  881. client_context: str | None = None,
  882. ) -> Awaitable[CommandResponse]:
  883. return self.send_item(
  884. thread_id,
  885. text=text,
  886. item_id=media_id,
  887. shh_mode=shh_mode,
  888. item_type=ThreadItemType.REEL_SHARE,
  889. client_context=client_context,
  890. )
  891. def send_text(
  892. self,
  893. thread_id: str,
  894. text: str = "",
  895. urls: list[str] | None = None,
  896. shh_mode: bool = False,
  897. client_context: str | None = None,
  898. replied_to_item_id: str | None = None,
  899. replied_to_client_context: str | None = None,
  900. mentioned_user_ids: list[int] | None = None,
  901. ) -> Awaitable[CommandResponse]:
  902. args = {
  903. "text": text,
  904. }
  905. item_type = ThreadItemType.TEXT
  906. if urls is not None:
  907. args = {
  908. "link_text": text,
  909. "link_urls": json.dumps(urls or []),
  910. }
  911. item_type = ThreadItemType.LINK
  912. if mentioned_user_ids:
  913. args["mentioned_user_ids"] = json.dumps([str(x) for x in mentioned_user_ids])
  914. args["sampled"] = True
  915. return self.send_item(
  916. thread_id,
  917. **args,
  918. shh_mode=shh_mode,
  919. item_type=item_type,
  920. client_context=client_context,
  921. replied_to_item_id=replied_to_item_id,
  922. replied_to_client_context=replied_to_client_context,
  923. send_attribution="direct_thread",
  924. send_silently=False,
  925. is_x_transport_forward=False,
  926. )
  927. def mark_seen(
  928. self, thread_id: str, item_id: str, client_context: str | None = None
  929. ) -> Awaitable[None]:
  930. return self.send_command(
  931. thread_id,
  932. item_id=item_id,
  933. action=ThreadAction.MARK_SEEN,
  934. client_context=client_context,
  935. )
  936. def mark_visual_item_seen(
  937. self, thread_id: str, item_id: str, client_context: str | None = None
  938. ) -> Awaitable[CommandResponse]:
  939. return self.send_command(
  940. thread_id,
  941. item_id=item_id,
  942. action=ThreadAction.MARK_VISUAL_ITEM_SEEN,
  943. client_context=client_context,
  944. )
  945. def indicate_activity(
  946. self,
  947. thread_id: str,
  948. activity_status: TypingStatus = TypingStatus.TEXT,
  949. client_context: str | None = None,
  950. ) -> Awaitable[CommandResponse]:
  951. return self.send_command(
  952. thread_id,
  953. activity_status=activity_status.value,
  954. action=ThreadAction.INDICATE_ACTIVITY,
  955. client_context=client_context,
  956. )
  957. # endregion