conn.py 38 KB

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