Sfoglia il codice sorgente

Reorganize types and other minor changes

Tulir Asokan 4 anni fa
parent
commit
4eab4eea24

+ 1 - 1
mauigpapi/errors/__init__.py

@@ -5,4 +5,4 @@ from .response import (IGResponseError, IGActionSpamError, IGNotFoundError, IGRa
                        IGCheckpointError, IGUserHasLoggedOutError, IGLoginRequiredError,
                        IGPrivateUserError, IGSentryBlockError, IGInactiveUserError, IGLoginError,
                        IGLoginTwoFactorRequiredError, IGLoginBadPasswordError,
-                       IGLoginInvalidUserError)
+                       IGLoginInvalidUserError, IGNotLoggedInError)

+ 6 - 2
mauigpapi/errors/response.py

@@ -65,11 +65,15 @@ class IGCheckpointError(IGResponseError):
         return self.body.challenge.api_path
 
 
-class IGUserHasLoggedOutError(IGResponseError):
+class IGNotLoggedInError(IGResponseError):
     body: LoginRequiredResponse
 
 
-class IGLoginRequiredError(IGResponseError):
+class IGUserHasLoggedOutError(IGNotLoggedInError):
+    body: LoginRequiredResponse
+
+
+class IGLoginRequiredError(IGNotLoggedInError):
     body: LoginRequiredResponse
 
 

+ 7 - 6
mauigpapi/http/base.py

@@ -14,6 +14,7 @@
 # You should have received a copy of the GNU Affero General Public License
 # along with this program.  If not, see <https://www.gnu.org/licenses/>.
 from typing import Optional, Dict, Any, TypeVar, Type
+import asyncio
 import random
 import time
 import json
@@ -54,7 +55,7 @@ class BaseAndroidAPI:
         return {"signed_body": f"SIGNATURE.{req}"}
 
     @property
-    def headers(self) -> Dict[str, str]:
+    def _headers(self) -> Dict[str, str]:
         headers = {
             "User-Agent": self.state.user_agent,
             "X-Ads-Opt-Out": str(int(self.state.session.ads_opt_out)),
@@ -93,7 +94,7 @@ class BaseAndroidAPI:
     async def std_http_post(self, path: str, data: Optional[JSON] = None, raw: bool = False,
                             filter_nulls: bool = False, headers: Optional[Dict[str, str]] = None,
                             response_type: Optional[Type[T]] = JSON) -> T:
-        headers = {**self.headers, **headers} if headers else self.headers
+        headers = {**self._headers, **headers} if headers else self._headers
         if not raw:
             data = self.sign(data, filter_nulls=filter_nulls)
         resp = await self.http.post(url=self.url.with_path(path), headers=headers, data=data)
@@ -103,7 +104,7 @@ class BaseAndroidAPI:
             if response_type is str:
                 return await resp.text()
             return None
-        json_data = await self.handle_response(resp)
+        json_data = await self._handle_response(resp)
         if response_type is not JSON:
             return response_type.deserialize(json_data)
         return json_data
@@ -111,19 +112,19 @@ class BaseAndroidAPI:
     async def std_http_get(self, path: str, query: Optional[Dict[str, str]] = None,
                            headers: Optional[Dict[str, str]] = None,
                            response_type: Optional[Type[T]] = JSON) -> T:
-        headers = {**self.headers, **headers} if headers else self.headers
+        headers = {**self._headers, **headers} if headers else self._headers
         query = {k: v for k, v in (query or {}).items() if v is not None}
         resp = await self.http.get(url=self.url.with_path(path).with_query(query), headers=headers)
         print(f"{path} response: {await resp.text()}")
         if response_type is None:
             self._handle_response_headers(resp)
             return None
-        json_data = await self.handle_response(resp)
+        json_data = await self._handle_response(resp)
         if response_type is not JSON:
             return response_type.deserialize(json_data)
         return json_data
 
-    async def handle_response(self, resp: ClientResponse) -> JSON:
+    async def _handle_response(self, resp: ClientResponse) -> JSON:
         self._handle_response_headers(resp)
         body = await resp.json()
         if body["status"] == "ok":

+ 12 - 12
mauigpapi/mqtt/conn.py

@@ -32,14 +32,13 @@ from mautrix.util.logging import TraceLogger
 
 from ..errors import NotLoggedIn, NotConnected
 from ..state import AndroidState
+from ..types import (CommandResponse, ThreadItemType, ThreadAction, ReactionStatus, TypingStatus,
+                     IrisPayload, PubsubPayload, AppPresenceEventPayload, RealtimeDirectEvent,
+                     RealtimeZeroProvisionPayload, ClientConfigUpdatePayload, MessageSyncEvent,
+                     MessageSyncMessage, LiveVideoCommentPayload, PubsubEvent)
 from .thrift import RealtimeConfig, RealtimeClientInfo, ForegroundStateConfig, IncomingMessage
 from .otclient import MQTToTClient
-from .subscription import everclear_subscriptions
-from .types import (RealtimeTopic, CommandResponse, ThreadItemType, ThreadAction, ReactionStatus,
-                    TypingStatus, IrisPayload, PubsubPayload, AppPresenceEventPayload,
-                    RealtimeDirectEvent, RealtimeZeroProvisionPayload, ClientConfigUpdatePayload,
-                    LiveVideoCommentPayload, PubsubEvent, MessageSyncEvent, MessageSyncMessage)
-from .subscription import GraphQLQueryID
+from .subscription import everclear_subscriptions, RealtimeTopic, GraphQLQueryID
 from .events import Connect, Disconnect
 
 try:
@@ -279,13 +278,13 @@ class AndroidMQTT:
 
     @staticmethod
     def _parse_realtime_sub_item(topic: str, raw: dict) -> Any:
-        if topic == GraphQLQueryID.appPresence:
+        if topic == GraphQLQueryID.APP_PRESENCE:
             return AppPresenceEventPayload.deserialize(raw).presence_event
-        elif topic == GraphQLQueryID.zeroProvision:
+        elif topic == GraphQLQueryID.ZERO_PROVISION:
             return RealtimeZeroProvisionPayload.deserialize(raw).zero_product_provisioning_event
-        elif topic == GraphQLQueryID.clientConfigUpdate:
+        elif topic == GraphQLQueryID.CLIENT_CONFIG_UPDATE:
             return ClientConfigUpdatePayload.deserialize(raw).client_config_update_event
-        elif topic == GraphQLQueryID.liveRealtimeComments:
+        elif topic == GraphQLQueryID.LIVE_REALTIME_COMMENTS:
             return LiveVideoCommentPayload.deserialize(raw).live_video_comment_event
         elif topic == "direct":
             return RealtimeDirectEvent.deserialize(raw)
@@ -293,8 +292,8 @@ class AndroidMQTT:
     def _on_realtime_sub(self, payload: bytes) -> None:
         parsed_thrift = IncomingMessage.from_thrift(payload)
         topic = parsed_thrift.topic
-        if topic not in ("direct", GraphQLQueryID.appPresence, GraphQLQueryID.zeroProvision,
-                         GraphQLQueryID.clientConfigUpdate, GraphQLQueryID.liveRealtimeComments):
+        if topic not in ("direct", GraphQLQueryID.APP_PRESENCE, GraphQLQueryID.ZERO_PROVISION,
+                         GraphQLQueryID.CLIENT_CONFIG_UPDATE, GraphQLQueryID.LIVE_REALTIME_COMMENTS):
             self.log.debug(f"Got unknown realtime sub event {topic}: {parsed_thrift.payload}")
         parsed_json = json.loads(parsed_thrift.payload)
         event = parsed_json["event"]
@@ -358,6 +357,7 @@ class AndroidMQTT:
         self._iris_seq_id = seq_id
         self._iris_snapshot_at_ms = snapshot_at_ms
 
+        self.log.debug("Connecting to Instagram MQTT")
         await self._reconnect()
         await self._dispatch(Connect())
         exit_if_not_connected = False

+ 87 - 44
mauigpapi/mqtt/subscription.py

@@ -13,7 +13,7 @@
 #
 # You should have received a copy of the GNU Affero General Public License
 # along with this program.  If not, see <https://www.gnu.org/licenses/>.
-from typing import Any, Optional, Union
+from typing import Any, Optional, Union, Dict
 from enum import Enum
 from uuid import uuid4
 import json
@@ -30,35 +30,35 @@ class SkywalkerSubscription:
 
 
 class GraphQLQueryID(Enum):
-    appPresence = '17846944882223835'
-    asyncAdSub = '17911191835112000'
-    clientConfigUpdate = '17849856529644700'
-    directStatus = '17854499065530643'
-    directTyping = '17867973967082385'
-    liveWave = '17882305414154951'
-    interactivityActivateQuestion = '18005526940184517'
-    interactivityRealtimeQuestionSubmissionsStatus = '18027779584026952'
-    interactivitySub = '17907616480241689'
-    liveRealtimeComments = '17855344750227125'
-    liveTypingIndicator = '17926314067024917'
-    mediaFeedback = '17877917527113814'
-    reactNativeOTA = '17861494672288167'
-    videoCallCoWatchControl = '17878679623388956'
-    videoCallInAlert = '17878679623388956'
-    videoCallPrototypePublish = '18031704190010162'
-    videoCallParticipantDelivery = '17977239895057311'
-    zeroProvision = '17913953740109069'
-    inappNotification = '17899377895239777'
-    businessDelivery = '17940467278199720'
+    APP_PRESENCE = "17846944882223835"
+    ASYNC_AD_SUB = "17911191835112000"
+    CLIENT_CONFIG_UPDATE = "17849856529644700"
+    DIRECT_STATUS = "17854499065530643"
+    DIRECT_TYPING = "17867973967082385"
+    LIVE_WAVE = "17882305414154951"
+    INTERACTIVITY_ACTIVATE_QUESTION = "18005526940184517"
+    INTERACTIVITY_REALTIME_QUESTION_SUBMISSION_STATUS = "18027779584026952"
+    INTERACTIVITY_SUB = "17907616480241689"
+    LIVE_REALTIME_COMMENTS = "17855344750227125"
+    LIVE_TYPING_INDICATOR = "17926314067024917"
+    MEDIA_FEEDBACK = "17877917527113814"
+    REACT_NATIVE_OTA = "17861494672288167"
+    VIDEO_CALL_CO_WATCH_CONTROL = "17878679623388956"
+    VIDEO_CALL_IN_ALERT = "17878679623388956"
+    VIDEO_CALL_PROTOTYPE_PUBLISH = "18031704190010162"
+    VIDEO_CALL_PARTICIPANT_DELIVERY = "17977239895057311"
+    ZERO_PROVISION = "17913953740109069"
+    INAPP_NOTIFICATION = "17899377895239777"
+    BUSINESS_DELIVERY = "17940467278199720"
 
 
 everclear_subscriptions = {
-    "async_ads_subscribe": GraphQLQueryID.asyncAdSub.value,
-    "inapp_notification_subscribe_default": GraphQLQueryID.inappNotification.value,
-    "inapp_notification_subscribe_comment": GraphQLQueryID.inappNotification.value,
-    "inapp_notification_subscribe_comment_mention_and_reply": GraphQLQueryID.inappNotification.value,
-    "business_import_page_media_delivery_subscribe": GraphQLQueryID.businessDelivery.value,
-    "video_call_participant_state_delivery": GraphQLQueryID.videoCallParticipantDelivery.value,
+    "async_ads_subscribe": GraphQLQueryID.ASYNC_AD_SUB.value,
+    "inapp_notification_subscribe_default": GraphQLQueryID.INAPP_NOTIFICATION.value,
+    "inapp_notification_subscribe_comment": GraphQLQueryID.INAPP_NOTIFICATION.value,
+    "inapp_notification_subscribe_comment_mention_and_reply": GraphQLQueryID.INAPP_NOTIFICATION.value,
+    "business_import_page_media_delivery_subscribe": GraphQLQueryID.BUSINESS_DELIVERY.value,
+    "video_call_participant_state_delivery": GraphQLQueryID.VIDEO_CALL_PARTICIPANT_DELIVERY.value,
 }
 
 
@@ -76,14 +76,14 @@ class GraphQLSubscription:
     @classmethod
     def app_presence(cls, subscription_id: Optional[str] = None,
                      client_logged: Optional[bool] = None) -> str:
-        return cls._fmt(GraphQLQueryID.appPresence,
+        return cls._fmt(GraphQLQueryID.APP_PRESENCE,
                         input_params={"client_subscription_id": subscription_id or str(uuid4())},
                         client_logged=client_logged)
 
     @classmethod
     def async_ad(cls, user_id: str, subscription_id: Optional[str] = None,
                  client_logged: Optional[bool] = None) -> str:
-        return cls._fmt(GraphQLQueryID.asyncAdSub,
+        return cls._fmt(GraphQLQueryID.ASYNC_AD_SUB,
                         input_params={"client_subscription_id": subscription_id or str(uuid4()),
                                       "user_id": user_id},
                         client_logged=client_logged)
@@ -91,20 +91,20 @@ class GraphQLSubscription:
     @classmethod
     def client_config_update(cls, subscription_id: Optional[str] = None,
                              client_logged: Optional[bool] = None) -> str:
-        return cls._fmt(GraphQLQueryID.clientConfigUpdate,
+        return cls._fmt(GraphQLQueryID.CLIENT_CONFIG_UPDATE,
                         input_params={"client_subscription_id": subscription_id or str(uuid4())},
                         client_logged=client_logged)
 
     @classmethod
     def direct_status(cls, subscription_id: Optional[str] = None,
                       client_logged: Optional[bool] = None) -> str:
-        return cls._fmt(GraphQLQueryID.directStatus,
+        return cls._fmt(GraphQLQueryID.DIRECT_STATUS,
                         input_params={"client_subscription_id": subscription_id or str(uuid4())},
                         client_logged=client_logged)
 
     @classmethod
     def direct_typing(cls, user_id: str, client_logged: Optional[bool] = None) -> str:
-        return cls._fmt(GraphQLQueryID.directTyping,
+        return cls._fmt(GraphQLQueryID.DIRECT_TYPING,
                         input_params={"user_id": user_id},
                         client_logged=client_logged)
 
@@ -112,7 +112,7 @@ class GraphQLSubscription:
     def ig_live_wave(cls, broadcast_id: str, receiver_id: str,
                      subscription_id: Optional[str] = None, client_logged: Optional[bool] = None
                      ) -> str:
-        return cls._fmt(GraphQLQueryID.liveWave,
+        return cls._fmt(GraphQLQueryID.LIVE_WAVE,
                         input_params={"client_subscription_id": subscription_id or str(uuid4()),
                                       "broadcast_id": broadcast_id, "receiver_id": receiver_id},
                         client_logged=client_logged)
@@ -121,7 +121,7 @@ class GraphQLSubscription:
     def interactivity_activate_question(cls, broadcast_id: str,
                                         subscription_id: Optional[str] = None,
                                         client_logged: Optional[bool] = None) -> str:
-        return cls._fmt(GraphQLQueryID.interactivityActivateQuestion,
+        return cls._fmt(GraphQLQueryID.INTERACTIVITY_ACTIVATE_QUESTION,
                         input_params={"client_subscription_id": subscription_id or str(uuid4()),
                                       "broadcast_id": broadcast_id},
                         client_logged=client_logged)
@@ -131,7 +131,7 @@ class GraphQLSubscription:
         cls, broadcast_id: str, subscription_id: Optional[str] = None,
         client_logged: Optional[bool] = None
     ) -> str:
-        return cls._fmt(GraphQLQueryID.interactivityRealtimeQuestionSubmissionsStatus,
+        return cls._fmt(GraphQLQueryID.INTERACTIVITY_REALTIME_QUESTION_SUBMISSION_STATUS,
                         input_params={"client_subscription_id": subscription_id or str(uuid4()),
                                       "broadcast_id": broadcast_id},
                         client_logged=client_logged)
@@ -139,7 +139,7 @@ class GraphQLSubscription:
     @classmethod
     def interactivity(cls, broadcast_id: str, subscription_id: Optional[str] = None,
                       client_logged: Optional[bool] = None) -> str:
-        return cls._fmt(GraphQLQueryID.interactivitySub,
+        return cls._fmt(GraphQLQueryID.INTERACTIVITY_SUB,
                         input_params={"client_subscription_id": subscription_id or str(uuid4()),
                                       "broadcast_id": broadcast_id},
                         client_logged=client_logged)
@@ -147,7 +147,7 @@ class GraphQLSubscription:
     @classmethod
     def live_realtime_comments(cls, broadcast_id: str, subscription_id: Optional[str] = None,
                                client_logged: Optional[bool] = None) -> str:
-        return cls._fmt(GraphQLQueryID.liveRealtimeComments,
+        return cls._fmt(GraphQLQueryID.LIVE_REALTIME_COMMENTS,
                         input_params={"client_subscription_id": subscription_id or str(uuid4()),
                                       "broadcast_id": broadcast_id},
                         client_logged=client_logged)
@@ -156,7 +156,7 @@ class GraphQLSubscription:
     def live_realtime_typing_indicator(cls, broadcast_id: str,
                                        subscription_id: Optional[str] = None,
                                        client_logged: Optional[bool] = None) -> str:
-        return cls._fmt(GraphQLQueryID.liveTypingIndicator,
+        return cls._fmt(GraphQLQueryID.LIVE_TYPING_INDICATOR,
                         input_params={"client_subscription_id": subscription_id or str(uuid4()),
                                       "broadcast_id": broadcast_id},
                         client_logged=client_logged)
@@ -164,7 +164,7 @@ class GraphQLSubscription:
     @classmethod
     def media_feedback(cls, feedback_id: str, subscription_id: Optional[str] = None,
                        client_logged: Optional[bool] = None) -> str:
-        return cls._fmt(GraphQLQueryID.mediaFeedback,
+        return cls._fmt(GraphQLQueryID.MEDIA_FEEDBACK,
                         input_params={"client_subscription_id": subscription_id or str(uuid4()),
                                       "feedback_id": feedback_id},
                         client_logged=client_logged)
@@ -172,7 +172,7 @@ class GraphQLSubscription:
     @classmethod
     def react_native_ota_update(cls, build_number: str, subscription_id: Optional[str] = None,
                                 client_logged: Optional[bool] = None) -> str:
-        return cls._fmt(GraphQLQueryID.reactNativeOTA,
+        return cls._fmt(GraphQLQueryID.REACT_NATIVE_OTA,
                         input_params={"client_subscription_id": subscription_id or str(uuid4()),
                                       "build_number": build_number},
                         client_logged=client_logged)
@@ -180,7 +180,7 @@ class GraphQLSubscription:
     @classmethod
     def video_call_co_watch_control(cls, video_call_id: str, subscription_id: Optional[str] = None,
                                     client_logged: Optional[bool] = None) -> str:
-        return cls._fmt(GraphQLQueryID.videoCallCoWatchControl,
+        return cls._fmt(GraphQLQueryID.VIDEO_CALL_CO_WATCH_CONTROL,
                         input_params={"client_subscription_id": subscription_id or str(uuid4()),
                                       "video_call_id": video_call_id},
                         client_logged=client_logged)
@@ -188,7 +188,7 @@ class GraphQLSubscription:
     @classmethod
     def video_call_in_call_alert(cls, video_call_id: str, subscription_id: Optional[str] = None,
                                  client_logged: Optional[bool] = None) -> str:
-        return cls._fmt(GraphQLQueryID.videoCallInAlert,
+        return cls._fmt(GraphQLQueryID.VIDEO_CALL_IN_ALERT,
                         input_params={"client_subscription_id": subscription_id or str(uuid4()),
                                       "video_call_id": video_call_id},
                         client_logged=client_logged)
@@ -197,7 +197,7 @@ class GraphQLSubscription:
     def video_call_prototype_publish(cls, video_call_id: str,
                                      subscription_id: Optional[str] = None,
                                      client_logged: Optional[bool] = None) -> str:
-        return cls._fmt(GraphQLQueryID.videoCallPrototypePublish,
+        return cls._fmt(GraphQLQueryID.VIDEO_CALL_PROTOTYPE_PUBLISH,
                         input_params={"client_subscription_id": subscription_id or str(uuid4()),
                                       "video_call_id": video_call_id},
                         client_logged=client_logged)
@@ -205,7 +205,50 @@ class GraphQLSubscription:
     @classmethod
     def zero_provision(cls, device_id: str, subscription_id: Optional[str] = None,
                        client_logged: Optional[bool] = None) -> str:
-        return cls._fmt(GraphQLQueryID.zeroProvision,
+        return cls._fmt(GraphQLQueryID.ZERO_PROVISION,
                         input_params={"client_subscription_id": subscription_id or str(uuid4()),
                                       "device_id": device_id},
                         client_logged=client_logged)
+
+
+_topic_map: Dict[str, str] = {
+    "/pp": "34",  # unknown
+    "/ig_sub_iris": "134",
+    "/ig_sub_iris_response": "135",
+    "/ig_message_sync": "146",
+    "/ig_send_message": "132",
+    "/ig_send_message_response": "133",
+    "/ig_realtime_sub": "149",
+    "/pubsub": "88",
+    "/t_fs": "102",  # Foreground state
+    "/graphql": "9",
+    "/t_region_hint": "150",
+    "/mqtt_health_stats": "/mqtt_health_stats",
+    "179": "179",  # also unknown
+}
+
+_reverse_topic_map: Dict[str, str] = {value: key for key, value in _topic_map.items()}
+
+
+class RealtimeTopic(Enum):
+    SUB_IRIS = "/ig_sub_iris"
+    SUB_IRIS_RESPONSE = "/ig_sub_iris_response"
+    MESSAGE_SYNC = "/ig_message_sync"
+    SEND_MESSAGE = "/ig_send_message"
+    SEND_MESSAGE_RESPONSE = "/ig_send_message_response"
+    REALTIME_SUB = "/ig_realtime_sub"
+    PUBSUB = "/pubsub"
+    FOREGROUND_STATE = "/t_fs"
+    GRAPHQL = "/graphql"
+    REGION_HINT = "/t_region_hint"
+    MQTT_HEALTH_STATS = "/mqtt_health_stats"
+    UNKNOWN_PP = "/pp"
+    UNKNOWN_179 = "179"
+
+    @property
+    def encoded(self) -> str:
+        return _topic_map[self.value]
+
+    @staticmethod
+    def decode(val: str) -> 'RealtimeTopic':
+        return RealtimeTopic(_reverse_topic_map[val])

+ 0 - 558
mauigpapi/mqtt/types.py

@@ -1,558 +0,0 @@
-# mautrix-instagram - A Matrix-Instagram puppeting bridge.
-# Copyright (C) 2020 Tulir Asokan
-#
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-#
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <https://www.gnu.org/licenses/>.
-from typing import Dict, Any, List, Optional
-import json
-
-from attr import dataclass
-import attr
-
-from mautrix.types import SerializableAttrs, SerializableEnum, JSON
-
-from .subscription import GraphQLQueryID
-
-_topic_map: Dict[str, str] = {
-    "/pp": "34",  # unknown
-    "/ig_sub_iris": "134",
-    "/ig_sub_iris_response": "135",
-    "/ig_message_sync": "146",
-    "/ig_send_message": "132",
-    "/ig_send_message_response": "133",
-    "/ig_realtime_sub": "149",
-    "/pubsub": "88",
-    "/t_fs": "102",  # Foreground state
-    "/graphql": "9",
-    "/t_region_hint": "150",
-    "/mqtt_health_stats": "/mqtt_health_stats",
-    "179": "179",  # also unknown
-}
-
-_reverse_topic_map: Dict[str, str] = {value: key for key, value in _topic_map.items()}
-
-
-class RealtimeTopic(SerializableEnum):
-    SUB_IRIS = "/ig_sub_iris"
-    SUB_IRIS_RESPONSE = "/ig_sub_iris_response"
-    MESSAGE_SYNC = "/ig_message_sync"
-    SEND_MESSAGE = "/ig_send_message"
-    SEND_MESSAGE_RESPONSE = "/ig_send_message_response"
-    REALTIME_SUB = "/ig_realtime_sub"
-    PUBSUB = "/pubsub"
-    FOREGROUND_STATE = "/t_fs"
-    GRAPHQL = "/graphql"
-    REGION_HINT = "/t_region_hint"
-    MQTT_HEALTH_STATS = "/mqtt_health_stats"
-    UNKNOWN_PP = "/pp"
-    UNKNOWN_179 = "179"
-
-    @property
-    def encoded(self) -> str:
-        return _topic_map[self.value]
-
-    @classmethod
-    def decode(cls, val: str) -> 'RealtimeTopic':
-        return cls(_reverse_topic_map[val])
-
-
-class ThreadItemType(SerializableEnum):
-    DELETION = "deletion"
-    MEDIA = "media"
-    TEXT = "text"
-    LIKE = "like"
-    HASHTAG = "hashtag"
-    PROFILE = "profile"
-    MEDIA_SHARE = "media_share"
-    LOCATION = "location"
-    ACTION_LOG = "action_log"
-    TITLE = "title"
-    USER_REACTION = "user_reaction"
-    HISTORY_EDIT = "history_edit"
-    REACTION_LOG = "reaction_log"
-    REEL_SHARE = "reel_share"
-    DEPRECATED_CHANNEL = "deprecated_channel"
-    LINK = "link"
-    RAVEN_MEDIA = "raven_media"
-    LIVE_VIDEO_SHARE = "live_video_share"
-    TEST = "test"
-    STORY_SHARE = "story_share"
-    REEL_REACT = "reel_react"
-    LIVE_INVITE_GUEST = "live_invite_guest"
-    LIVE_VIEWER_INVITE = "live_viewer_invite"
-    TYPE_MAX = "type_max"
-    PLACEHOLDER = "placeholder"
-    PRODUCT = "product"
-    PRODUCT_SHARE = "product_share"
-    VIDEO_CALL_EVENT = "video_call_event"
-    POLL_VOTE = "poll_vote"
-    FELIX_SHARE = "felix_share"
-    ANIMATED_MEDIA = "animated_media"
-    CTA_LINK = "cta_link"
-    VOICE_MEDIA = "voice_media"
-    STATIC_STICKER = "static_sticker"
-    AR_EFFECT = "ar_effect"
-    SELFIE_STICKER = "selfie_sticker"
-    REACTION = "reaction"
-
-
-class Operation(SerializableEnum):
-    ADD = "add"
-    REPLACE = "replace"
-    REMOVE = "remove"
-
-
-class ThreadAction(SerializableEnum):
-    SEND_ITEM = "send_item"
-    PROFILE = "profile"
-    MARK_SEEN = "mark_seen"
-    MARK_VISUAL_ITEM_SEEN = "mark_visual_item_seen"
-    INDICATE_ACTIVITY = "indicate_activity"
-
-
-class ReactionStatus(SerializableEnum):
-    CREATED = "created"
-    DELETED = "deleted"
-
-
-class TypingStatus(SerializableEnum):
-    OFF = 0
-    TEXT = 1
-    VISUAL = 2
-
-
-@dataclass(kw_only=True)
-class CommandResponsePayload(SerializableAttrs['CommandResponsePayload']):
-    client_context: Optional[str] = None
-    item_id: Optional[str] = None
-    timestamp: Optional[str] = None
-    thread_id: Optional[str] = None
-
-
-@dataclass(kw_only=True)
-class CommandResponse(SerializableAttrs['CommandResponse']):
-    action: str
-    status: str
-    status_code: str
-    payload: CommandResponsePayload
-
-
-@dataclass(kw_only=True)
-class IrisPayloadData(SerializableAttrs['IrisPayloadData']):
-    op: Operation
-    path: str
-    value: str
-
-
-@dataclass(kw_only=True)
-class IrisPayload(SerializableAttrs['IrisPayload']):
-    data: List[IrisPayloadData]
-    message_type: int
-    seq_id: int
-    event: str = "patch"
-    mutation_token: Optional[str] = None
-    realtime: Optional[bool] = None
-    sampled: Optional[bool] = None
-
-
-class ViewMode(SerializableEnum):
-    ONCE = "once"
-    REPLAYABLE = "replayable"
-    PERMANENT = "permanent"
-
-
-@dataclass(kw_only=True)
-class CreativeConfig(SerializableAttrs['CreativeConfig']):
-    capture_type: str
-    camera_facing: str
-    should_render_try_it_on: bool
-
-
-@dataclass(kw_only=True)
-class CreateModeAttribution(SerializableAttrs['CreateModeAttribution']):
-    type: str
-    name: str
-
-
-@dataclass(kw_only=True)
-class ImageVersion(SerializableAttrs['ImageVersion']):
-    width: int
-    height: int
-    url: str
-    estimated_scan_sizes: Optional[List[int]] = None
-
-
-@dataclass(kw_only=True)
-class ImageVersions(SerializableAttrs['ImageVersions']):
-    candidates: List[ImageVersion]
-
-
-@dataclass(kw_only=True)
-class VideoVersion(SerializableAttrs['VideoVersion']):
-    type: int
-    width: int
-    height: int
-    url: str
-    id: str
-
-
-class MediaType(SerializableEnum):
-    IMAGE = 1
-    VIDEO = 2
-    AD_MAP = 6
-    LIVE = 7
-    CAROUSEL = 8
-    LIVE_REPLAY = 9
-    COLLECTION = 10
-    AUDIO = 11
-    SHOWREEL_NATIVE = 12
-
-
-@dataclass(kw_only=True)
-class RegularMediaItem(SerializableAttrs['RegularMediaItem']):
-    id: str
-    image_versions2: Optional[ImageVersions] = None
-    video_versions: Optional[List[VideoVersion]] = None
-    original_width: int
-    original_height: int
-    media_type: MediaType
-    media_id: Optional[int] = None
-    organic_tracking_token: Optional[str] = None
-    creative_config: Optional[CreativeConfig] = None
-    create_mode_attribution: Optional[CreateModeAttribution] = None
-
-
-@dataclass(kw_only=True)
-class FriendshipStatus(SerializableAttrs['FriendshipStatus']):
-    following: bool
-    outgoing_request: bool
-    is_bestie: bool
-    is_restricted: bool
-
-
-@dataclass(kw_only=True)
-class MinimalUser(SerializableAttrs['MinimalUser']):
-    pk: int
-    username: str
-
-
-@dataclass(kw_only=True)
-class User(MinimalUser, SerializableAttrs['User']):
-    full_name: str
-    is_private: bool
-    is_favorite: bool
-    is_unpublished: bool
-    has_anonymous_profile_picture: bool
-    profile_pic_url: str
-    profile_pic_id: str
-    latest_reel_media: int
-    friendship_status: FriendshipStatus
-
-
-@dataclass(kw_only=True)
-class Caption(SerializableAttrs['Caption']):
-    pk: int
-    user_id: int
-    text: str
-    # TODO enum?
-    type: int
-    created_at: int
-    created_at_utc: int
-    content_type: str
-    # TODO enum?
-    status: str
-    bit_flags: int
-    user: User
-    did_report_as_spam: bool
-    share_enabled: bool
-    media_id: int
-
-
-@dataclass(kw_only=True)
-class MediaShareItem(SerializableAttrs['MediaShareItem']):
-    taken_at: int
-    pk: int
-    id: str
-    device_timestamp: int
-    media_type: MediaType
-    code: str
-    client_cache_key: str
-    filter_type: int
-    image_versions2: ImageVersions
-    video_versions: VideoVersion
-    original_width: int
-    original_height: int
-    user: User
-    can_viewer_reshare: bool
-    caption_is_edited: bool
-    comment_likes_enabled: bool
-    comment_threading_enabled: bool
-    has_more_comments: bool
-    max_num_visible_preview_comments: int
-    can_view_more_preview_comments: bool
-    comment_count: int
-    like_count: int
-    has_liked: bool
-    photo_of_you: bool
-    caption: Caption
-    can_viewer_save: bool
-    organic_tracking_token: str
-
-
-@dataclass(kw_only=True)
-class ReplayableMediaItem(SerializableAttrs['ReplayableMediaItem']):
-    view_mode: ViewMode
-    seen_count: int
-    seen_user_ids: List[int]
-    replay_expiring_at_us: Optional[Any] = None
-
-
-@dataclass(kw_only=True)
-class VisualMedia(ReplayableMediaItem, SerializableAttrs['VisualMedia']):
-    url_expire_at_secs: int
-    playback_duration_secs: int
-    media: RegularMediaItem
-
-
-@dataclass(kw_only=True)
-class AudioInfo(SerializableAttrs['AudioInfo']):
-    audio_src: str
-    duration: int
-    waveform_data: List[int]
-    waveform_sampling_frequence_hz: int
-
-
-@dataclass(kw_only=True)
-class VoiceMediaData(SerializableAttrs['VoiceMediaData']):
-    id: str
-    audio: AudioInfo
-    organic_tracking_token: str
-    user: MinimalUser
-    # TODO enum?
-    product_type: str = "direct_audio"
-    media_type: MediaType = MediaType.AUDIO
-
-
-@dataclass(kw_only=True)
-class VoiceMediaItem(ReplayableMediaItem, SerializableAttrs['VoiceMediaItem']):
-    media: VoiceMediaData
-
-
-@dataclass(kw_only=True)
-class AnimatedMediaImage(SerializableAttrs['AnimatedMediaImage']):
-    height: str
-    mp4: str
-    mp4_size: str
-    size: str
-    url: str
-    webp: str
-    webp_size: str
-    width: str
-
-
-@dataclass(kw_only=True)
-class AnimatedMediaImages(SerializableAttrs['AnimatedMediaImages']):
-    fixed_height: Optional[AnimatedMediaImage] = None
-
-
-@dataclass(kw_only=True)
-class AnimatedMediaItem(SerializableAttrs['AnimatedMediaItem']):
-    id: str
-    is_random: str
-    is_sticker: str
-    images: AnimatedMediaImages
-
-
-@dataclass(kw_only=True)
-class MessageSyncMessage(SerializableAttrs['MessageSyncMessage']):
-    thread_id: str
-    item_id: Optional[str] = None
-    admin_user_ids: Optional[int] = None
-    approval_required_for_new_members: Optional[bool] = None
-    participants: Optional[Dict[str, str]] = None
-    # TODO enum
-    op: Operation = Operation.ADD
-    path: str
-    user_id: Optional[int] = None
-    timestamp: int
-    item_type: Optional[ThreadItemType] = None
-    text: Optional[str] = None
-    media: Optional[RegularMediaItem] = None
-    voice_media: Optional[VoiceMediaItem] = None
-    animated_media: Optional[AnimatedMediaItem] = None
-    visual_media: Optional[VisualMedia] = None
-    media_share: Optional[MediaShareItem] = None
-    reactions: Optional[dict] = None
-
-
-@dataclass(kw_only=True)
-class MessageSyncEvent(SerializableAttrs['MessageSyncEvent']):
-    iris: IrisPayload
-    message: MessageSyncMessage
-
-
-@dataclass(kw_only=True)
-class PubsubPublishMetadata(SerializableAttrs['PubsubPublishMetadata']):
-    publish_time_ms: str
-    topic_publish_id: int
-
-
-@dataclass(kw_only=True)
-class PubsubBasePayload(SerializableAttrs['PubsubBasePayload']):
-    lazy: bool
-    event: str = "patch"
-    publish_metadata: Optional[PubsubPublishMetadata] = None
-    num_endpoints: Optional[int] = None
-
-
-@dataclass(kw_only=True)
-class ActivityIndicatorData(SerializableAttrs['ActivityIndicatorData']):
-    timestamp: str
-    sender_id: str
-    ttl: int
-    activity_status: TypingStatus
-
-    @classmethod
-    def deserialize(cls, data: JSON) -> 'ActivityIndicatorData':
-        # The ActivityIndicatorData in PubsubPayloadData is actually a string,
-        # so we need to unmarshal it first.
-        if isinstance(data, str):
-            data = json.loads(data)
-        return super().deserialize(data)
-
-
-@dataclass(kw_only=True)
-class PubsubPayloadData(SerializableAttrs['PubsubPayloadData']):
-    double_publish: bool = attr.ib(metadata={"json": "doublePublish"})
-    value: ActivityIndicatorData
-    path: str
-    op: Operation = Operation.ADD
-
-
-@dataclass(kw_only=True)
-class PubsubPayload(PubsubBasePayload, SerializableAttrs['PubsubPayload']):
-    data: List[PubsubPayloadData]
-
-
-@dataclass(kw_only=True)
-class PubsubEvent(SerializableAttrs['PubsubEvent']):
-    base: PubsubBasePayload
-    data: PubsubPayloadData
-    thread_id: str
-    activity_indicator_id: str
-
-
-@dataclass(kw_only=True)
-class AppPresenceEvent(SerializableAttrs['AppPresenceEvent']):
-    user_id: str
-    is_active: bool
-    last_activity_at_ms: str
-    in_threads: List[Any]
-
-
-@dataclass(kw_only=True)
-class AppPresenceEventPayload(SerializableAttrs['AppPresenceEventPayload']):
-    presence_event: AppPresenceEvent
-
-
-@dataclass(kw_only=True)
-class ZeroProductProvisioningEvent(SerializableAttrs['ZeroProductProvisioningEvent']):
-    device_id: str
-    product_name: str
-    zero_provisioned_time: str
-
-
-@dataclass(kw_only=True)
-class RealtimeZeroProvisionPayload(SerializableAttrs['RealtimeZeroProvisionPayload']):
-    zero_product_provisioning_event: ZeroProductProvisioningEvent
-
-
-@dataclass(kw_only=True)
-class ClientConfigUpdateEvent(SerializableAttrs['ClientConfigUpdateEvent']):
-    publish_id: str
-    client_config_name: str
-    backing: str = "QE"
-    client_subscription_id: str = GraphQLQueryID.clientConfigUpdate
-
-
-@dataclass(kw_only=True)
-class ClientConfigUpdatePayload(SerializableAttrs['ClientConfigUpdatePayload']):
-    client_config_update_event: ClientConfigUpdateEvent
-
-
-RealtimeDirectData = ActivityIndicatorData
-
-
-@dataclass(kw_only=True)
-class RealtimeDirectEvent(SerializableAttrs['RealtimeDirectEvent']):
-    op: Operation
-    path: str
-    value: RealtimeDirectData
-
-
-@dataclass(kw_only=True)
-class LiveVideoCommentUser(SerializableAttrs['LiveVideoCommentUser']):
-    pk: str
-    username: str
-    full_name: str
-    is_private: bool
-    is_verified: bool
-    profile_pic_url: str
-    profile_pic_id: Optional[str] = None
-
-
-@dataclass(kw_only=True)
-class LiveVideoSystemComment(SerializableAttrs['LiveVideoSystemComment']):
-    pk: str
-    created_at: int
-    text: str
-    user_count: int
-    user: LiveVideoCommentUser
-
-
-@dataclass(kw_only=True)
-class LiveVideoComment(SerializableAttrs['LiveVideoComment']):
-    pk: str
-    user_id: str
-    text: str
-    type: int
-    created_at: int
-    created_at_utc: int
-    content_type: str
-    status: str = "Active"
-    bit_flags: int
-    did_report_as_spam: bool
-    inline_composer_display_condition: str
-    user: LiveVideoCommentUser
-
-
-@dataclass(kw_only=True)
-class LiveVideoCommentEvent(SerializableAttrs['LiveVideoCommentEvent']):
-    client_subscription_id: str
-    live_seconds_per_comment: int
-    comment_likes_enabled: bool
-    comment_count: int
-    caption: Optional[str] = None
-    caption_is_edited: bool
-    has_more_comments: bool
-    has_more_headload_comments: bool
-    media_header_display: str
-    comment_muted: int
-    comments: Optional[List[LiveVideoComment]] = None
-    pinned_comment: Optional[LiveVideoComment] = None
-    system_comments: Optional[List[LiveVideoSystemComment]] = None
-
-
-@dataclass(kw_only=True)
-class LiveVideoCommentPayload(SerializableAttrs['LiveVideoCommentPayload']):
-    live_video_comment_event: LiveVideoCommentEvent

+ 18 - 2
mauigpapi/types/__init__.py

@@ -3,6 +3,22 @@ from .error import (SpamResponse, CheckpointResponse, CheckpointChallenge,
                     LoginRequiredResponse, LoginErrorResponse, LoginErrorResponseButton,
                     LoginPhoneVerificationSettings, LoginTwoFactorInfo)
 from .login import LoginResponseUser, LoginResponseNametag, LoginResponse, LogoutResponse
-from .account import CurrentUser, EntityText, HDProfilePictureVersion, CurrentUserResponse
-from .direct_inbox import DirectInboxResponse
+from .account import (CurrentUser, EntityText, HDProfilePictureVersion, CurrentUserResponse,
+                      FriendshipStatus, UserIdentifier, BaseFullResponseUser, BaseResponseUser,
+                      ProfileEditParams)
+from .direct_inbox import (DirectInboxResponse, DirectInboxUser, DirectInboxCursor, DirectInbox,
+                           DirectInboxThreadTheme, DirectInboxThread, UserLastSeenAt)
 from .upload import UploadPhotoResponse
+from .thread import (ThreadItemType, ThreadItemActionLog, ViewMode, CreativeConfig, MediaType,
+                     CreateModeAttribution, ImageVersion, ImageVersions, VideoVersion, Caption,
+                     RegularMediaItem, MediaShareItem, ReplayableMediaItem, VisualMedia, AudioInfo,
+                     VoiceMediaItem, AnimatedMediaImage, AnimatedMediaImages, AnimatedMediaItem,
+                     ThreadItem, VoiceMediaData)
+from .mqtt import (Operation, ThreadAction, ReactionStatus, TypingStatus, CommandResponsePayload,
+                   CommandResponse, IrisPayloadData, IrisPayload, MessageSyncMessage,
+                   MessageSyncEvent, PubsubBasePayload, PubsubPublishMetadata, PubsubPayloadData,
+                   ActivityIndicatorData, PubsubEvent, PubsubPayload, AppPresenceEventPayload,
+                   AppPresenceEvent, ZeroProductProvisioningEvent, RealtimeZeroProvisionPayload,
+                   ClientConfigUpdatePayload, ClientConfigUpdateEvent, RealtimeDirectData,
+                   RealtimeDirectEvent, LiveVideoSystemComment, LiveVideoCommentEvent,
+                   LiveVideoComment, LiveVideoCommentPayload)

+ 33 - 7
mauigpapi/types/account.py

@@ -21,23 +21,50 @@ from mautrix.types import SerializableAttrs
 
 
 @dataclass(kw_only=True)
-class BaseResponseUser(SerializableAttrs['BaseResponseUser']):
+class FriendshipStatus(SerializableAttrs['FriendshipStatus']):
+    following: bool
+    outgoing_request: bool
+    is_bestie: bool
+    is_restricted: bool
+    blocking: Optional[bool] = None
+    incoming_request: Optional[bool] = None
+    is_private: Optional[bool] = None
+
+
+@dataclass(kw_only=True)
+class UserIdentifier(SerializableAttrs['UserIdentifier']):
     pk: int
     username: str
+
+
+@dataclass(kw_only=True)
+class BaseResponseUser(UserIdentifier, SerializableAttrs['BaseResponseUser']):
     full_name: str
     is_private: bool
+    is_verified: bool
     profile_pic_url: str
     # When this doesn't exist, the profile picture is probably the default one
     profile_pic_id: Optional[str] = None
-    is_verified: bool
-    has_anonymous_profile_picture: bool
 
+    has_anonymous_profile_picture: bool = False
+    # TODO find type
+    account_badges: Optional[List[Any]] = None
+
+    # TODO enum? only present for self
+    reel_auto_archive: Optional[str] = None
+    # Only present for not-self
+    friendship_status: Optional[FriendshipStatus] = None
+    # Not exactly sure when this is present
+    latest_reel_media: Optional[int] = None
+
+
+@dataclass(kw_only=True)
+class BaseFullResponseUser(BaseResponseUser, SerializableAttrs['BaseFullResponseUser']):
     phone_number: str
     country_code: Optional[int] = None
     national_number: Optional[int] = None
 
-    # TODO enum both of these?
-    reel_auto_archive: str
+    # TODO enum?
     allowed_commenter_type: str
 
     # These are at least in login and current_user, might not be in other places though
@@ -45,7 +72,6 @@ class BaseResponseUser(SerializableAttrs['BaseResponseUser']):
     # TODO enum?
     account_type: int
     is_call_to_action_enabled: Any
-    account_badges: List[Any]
 
 
 @dataclass
@@ -71,7 +97,7 @@ class ProfileEditParams(SerializableAttrs['ProfileEditParams']):
 
 
 @dataclass(kw_only=True)
-class CurrentUser(BaseResponseUser, SerializableAttrs['CurrentUser']):
+class CurrentUser(BaseFullResponseUser, SerializableAttrs['CurrentUser']):
     biography: str
     can_link_entities_in_bio: bool
     biography_with_entities: EntityText

+ 89 - 3
mauigpapi/types/direct_inbox.py

@@ -13,11 +13,95 @@
 #
 # You should have received a copy of the GNU Affero General Public License
 # along with this program.  If not, see <https://www.gnu.org/licenses/>.
-from typing import Any
+from typing import List, Any, Dict, Optional
 
 from attr import dataclass
 from mautrix.types import SerializableAttrs
 
+from .account import BaseResponseUser
+from .thread import ThreadItem
+
+
+@dataclass
+class DirectInboxUser(BaseResponseUser, SerializableAttrs['DirectInboxViewer']):
+    interop_messaging_user_fbid: int
+    is_using_unified_inbox_for_direct: bool
+
+
+@dataclass
+class DirectInboxCursor(SerializableAttrs['DirectInboxCursor']):
+    cursor_timestamp_seconds: int
+    cursor_thread_v2_id: int
+
+
+@dataclass
+class DirectInboxThreadTheme(SerializableAttrs['DirectInboxThreadTheme']):
+    id: str
+
+
+@dataclass
+class UserLastSeenAt(SerializableAttrs['UserLastSeenAt']):
+    timestamp: str
+    item_id: str
+    shh_seen_state: Dict[str, Any]
+
+
+@dataclass
+class DirectInboxThread(SerializableAttrs['DirectInboxThread']):
+    thread_id: str
+    thread_v2_id: str
+
+    users: List[DirectInboxUser]
+    inviter: BaseResponseUser
+    admin_user_ids: List[int]
+
+    last_activity_at: int
+    muted: bool
+    is_pin: bool
+    named: bool
+    canonical: bool
+    pending: bool
+    archived: bool
+    # TODO enum? even groups seem to be "private"
+    thread_type: str
+    viewer_id: int
+    thread_title: str
+    folder: int
+    vc_muted: bool
+    is_group: bool
+    mentions_muted: bool
+    approval_required_for_new_members: bool
+    input_mode: int
+    business_thread_folder: int
+    read_state: int
+    last_non_sender_item_at: int
+    assigned_admin_id: int
+    shh_mode_enabled: bool
+    is_close_friend_thread: bool
+    has_older: bool
+    has_newer: bool
+
+    theme: DirectInboxThreadTheme
+    last_seen_at: Dict[int, UserLastSeenAt]
+
+    newest_cursor: str
+    oldest_cursor: str
+    next_cursor: str
+    prev_cursor: str
+    last_permanent_item: ThreadItem
+    items: List[ThreadItem]
+
+
+@dataclass
+class DirectInbox(SerializableAttrs['DirectInbox']):
+    threads: List[DirectInboxThread]
+    has_older: bool
+    unseen_count: int
+    unseen_count_ts: int
+    prev_cursor: DirectInboxCursor
+    next_cursor: DirectInboxCursor
+    blended_inbox_enabled: bool
+
 
 @dataclass
 class DirectInboxResponse(SerializableAttrs['DirectInboxFeedResponse']):
@@ -25,6 +109,8 @@ class DirectInboxResponse(SerializableAttrs['DirectInboxFeedResponse']):
     seq_id: int
     snapshot_at_ms: int
     pending_requests_total: int
-    # TODO
-    inbox: Any
+    has_pending_top_requests: bool
+    viewer: DirectInboxUser
+    inbox: DirectInbox
+    # TODO type
     most_recent_inviter: Any = None

+ 2 - 3
mauigpapi/types/login.py

@@ -19,7 +19,7 @@ from attr import dataclass
 
 from mautrix.types import SerializableAttrs
 
-from .account import BaseResponseUser
+from .account import BaseFullResponseUser
 
 
 @dataclass
@@ -31,7 +31,7 @@ class LoginResponseNametag(SerializableAttrs['LoginResponseNametag']):
 
 
 @dataclass
-class LoginResponseUser(BaseResponseUser, SerializableAttrs['LoginResponseUser']):
+class LoginResponseUser(BaseFullResponseUser, SerializableAttrs['LoginResponseUser']):
     can_boost_post: bool
     can_see_organic_insights: bool
     show_insights_terms: bool
@@ -39,7 +39,6 @@ class LoginResponseUser(BaseResponseUser, SerializableAttrs['LoginResponseUser']
     nametag: LoginResponseNametag
     allow_contacts_sync: bool
 
-    # These are from manually observed responses rather than igpapi
     total_igtv_videos: int
     interop_messaging_user_fbid: int
     is_using_unified_inbox_for_direct: bool

+ 248 - 0
mauigpapi/types/mqtt.py

@@ -0,0 +1,248 @@
+# mautrix-instagram - A Matrix-Instagram puppeting bridge.
+# Copyright (C) 2020 Tulir Asokan
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <https://www.gnu.org/licenses/>.
+from typing import Dict, Any, List, Optional
+import json
+
+from attr import dataclass
+import attr
+
+from mautrix.types import SerializableAttrs, SerializableEnum, JSON
+
+from .thread import ThreadItem
+from .account import BaseResponseUser
+
+
+class Operation(SerializableEnum):
+    ADD = "add"
+    REPLACE = "replace"
+    REMOVE = "remove"
+
+
+class ThreadAction(SerializableEnum):
+    SEND_ITEM = "send_item"
+    PROFILE = "profile"
+    MARK_SEEN = "mark_seen"
+    MARK_VISUAL_ITEM_SEEN = "mark_visual_item_seen"
+    INDICATE_ACTIVITY = "indicate_activity"
+
+
+class ReactionStatus(SerializableEnum):
+    CREATED = "created"
+    DELETED = "deleted"
+
+
+class TypingStatus(SerializableEnum):
+    OFF = 0
+    TEXT = 1
+    VISUAL = 2
+
+
+@dataclass(kw_only=True)
+class CommandResponsePayload(SerializableAttrs['CommandResponsePayload']):
+    client_context: Optional[str] = None
+    item_id: Optional[str] = None
+    timestamp: Optional[str] = None
+    thread_id: Optional[str] = None
+
+
+@dataclass(kw_only=True)
+class CommandResponse(SerializableAttrs['CommandResponse']):
+    action: str
+    status: str
+    status_code: str
+    payload: CommandResponsePayload
+
+
+@dataclass(kw_only=True)
+class IrisPayloadData(SerializableAttrs['IrisPayloadData']):
+    op: Operation
+    path: str
+    value: str
+
+
+@dataclass(kw_only=True)
+class IrisPayload(SerializableAttrs['IrisPayload']):
+    data: List[IrisPayloadData]
+    message_type: int
+    seq_id: int
+    event: str = "patch"
+    mutation_token: Optional[str] = None
+    realtime: Optional[bool] = None
+    sampled: Optional[bool] = None
+
+
+@dataclass(kw_only=True)
+class MessageSyncMessage(ThreadItem, SerializableAttrs['MessageSyncMessage']):
+    path: str
+    op: Operation = Operation.ADD
+    # TODO some or all of these might be in direct_inbox too
+    admin_user_ids: Optional[int] = None
+    approval_required_for_new_members: Optional[bool] = None
+    participants: Optional[Dict[str, str]] = None
+    reactions: Optional[dict] = None
+
+
+@dataclass(kw_only=True)
+class MessageSyncEvent(SerializableAttrs['MessageSyncEvent']):
+    iris: IrisPayload
+    message: MessageSyncMessage
+
+
+@dataclass(kw_only=True)
+class PubsubPublishMetadata(SerializableAttrs['PubsubPublishMetadata']):
+    publish_time_ms: str
+    topic_publish_id: int
+
+
+@dataclass(kw_only=True)
+class PubsubBasePayload(SerializableAttrs['PubsubBasePayload']):
+    lazy: bool
+    event: str = "patch"
+    publish_metadata: Optional[PubsubPublishMetadata] = None
+    num_endpoints: Optional[int] = None
+
+
+@dataclass(kw_only=True)
+class ActivityIndicatorData(SerializableAttrs['ActivityIndicatorData']):
+    timestamp: str
+    sender_id: str
+    ttl: int
+    activity_status: TypingStatus
+
+    @classmethod
+    def deserialize(cls, data: JSON) -> 'ActivityIndicatorData':
+        # The ActivityIndicatorData in PubsubPayloadData is actually a string,
+        # so we need to unmarshal it first.
+        if isinstance(data, str):
+            data = json.loads(data)
+        return super().deserialize(data)
+
+
+@dataclass(kw_only=True)
+class PubsubPayloadData(SerializableAttrs['PubsubPayloadData']):
+    double_publish: bool = attr.ib(metadata={"json": "doublePublish"})
+    value: ActivityIndicatorData
+    path: str
+    op: Operation = Operation.ADD
+
+
+@dataclass(kw_only=True)
+class PubsubPayload(PubsubBasePayload, SerializableAttrs['PubsubPayload']):
+    data: List[PubsubPayloadData]
+
+
+@dataclass(kw_only=True)
+class PubsubEvent(SerializableAttrs['PubsubEvent']):
+    base: PubsubBasePayload
+    data: PubsubPayloadData
+    thread_id: str
+    activity_indicator_id: str
+
+
+@dataclass(kw_only=True)
+class AppPresenceEvent(SerializableAttrs['AppPresenceEvent']):
+    user_id: str
+    is_active: bool
+    last_activity_at_ms: str
+    in_threads: List[Any]
+
+
+@dataclass(kw_only=True)
+class AppPresenceEventPayload(SerializableAttrs['AppPresenceEventPayload']):
+    presence_event: AppPresenceEvent
+
+
+@dataclass(kw_only=True)
+class ZeroProductProvisioningEvent(SerializableAttrs['ZeroProductProvisioningEvent']):
+    device_id: str
+    product_name: str
+    zero_provisioned_time: str
+
+
+@dataclass(kw_only=True)
+class RealtimeZeroProvisionPayload(SerializableAttrs['RealtimeZeroProvisionPayload']):
+    zero_product_provisioning_event: ZeroProductProvisioningEvent
+
+
+@dataclass(kw_only=True)
+class ClientConfigUpdateEvent(SerializableAttrs['ClientConfigUpdateEvent']):
+    publish_id: str
+    client_config_name: str
+    backing: str  # might be "QE"
+    client_subscription_id: str  # should be GraphQLQueryID.clientConfigUpdate
+
+
+@dataclass(kw_only=True)
+class ClientConfigUpdatePayload(SerializableAttrs['ClientConfigUpdatePayload']):
+    client_config_update_event: ClientConfigUpdateEvent
+
+
+# TODO figure out if these need to be separate
+RealtimeDirectData = ActivityIndicatorData
+
+
+@dataclass(kw_only=True)
+class RealtimeDirectEvent(SerializableAttrs['RealtimeDirectEvent']):
+    op: Operation
+    path: str
+    value: RealtimeDirectData
+
+
+@dataclass(kw_only=True)
+class LiveVideoSystemComment(SerializableAttrs['LiveVideoSystemComment']):
+    pk: str
+    created_at: int
+    text: str
+    user_count: int
+    user: BaseResponseUser
+
+
+@dataclass(kw_only=True)
+class LiveVideoComment(SerializableAttrs['LiveVideoComment']):
+    pk: str
+    user_id: str
+    text: str
+    type: int
+    created_at: int
+    created_at_utc: int
+    content_type: str
+    status: str = "Active"
+    bit_flags: int
+    did_report_as_spam: bool
+    inline_composer_display_condition: str
+    user: BaseResponseUser
+
+
+@dataclass(kw_only=True)
+class LiveVideoCommentEvent(SerializableAttrs['LiveVideoCommentEvent']):
+    client_subscription_id: str
+    live_seconds_per_comment: int
+    comment_likes_enabled: bool
+    comment_count: int
+    caption: Optional[str] = None
+    caption_is_edited: bool
+    has_more_comments: bool
+    has_more_headload_comments: bool
+    media_header_display: str
+    comment_muted: int
+    comments: Optional[List[LiveVideoComment]] = None
+    pinned_comment: Optional[LiveVideoComment] = None
+    system_comments: Optional[List[LiveVideoSystemComment]] = None
+
+
+@dataclass(kw_only=True)
+class LiveVideoCommentPayload(SerializableAttrs['LiveVideoCommentPayload']):
+    live_video_comment_event: LiveVideoCommentEvent

+ 269 - 0
mauigpapi/types/thread.py

@@ -0,0 +1,269 @@
+# mautrix-instagram - A Matrix-Instagram puppeting bridge.
+# Copyright (C) 2020 Tulir Asokan
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <https://www.gnu.org/licenses/>.
+from typing import List, Any, Dict, Optional
+
+from attr import dataclass
+from mautrix.types import SerializableAttrs, SerializableEnum
+
+from .account import BaseResponseUser, UserIdentifier
+
+
+class ThreadItemType(SerializableEnum):
+    DELETION = "deletion"
+    MEDIA = "media"
+    TEXT = "text"
+    LIKE = "like"
+    HASHTAG = "hashtag"
+    PROFILE = "profile"
+    MEDIA_SHARE = "media_share"
+    LOCATION = "location"
+    ACTION_LOG = "action_log"
+    TITLE = "title"
+    USER_REACTION = "user_reaction"
+    HISTORY_EDIT = "history_edit"
+    REACTION_LOG = "reaction_log"
+    REEL_SHARE = "reel_share"
+    DEPRECATED_CHANNEL = "deprecated_channel"
+    LINK = "link"
+    RAVEN_MEDIA = "raven_media"
+    LIVE_VIDEO_SHARE = "live_video_share"
+    TEST = "test"
+    STORY_SHARE = "story_share"
+    REEL_REACT = "reel_react"
+    LIVE_INVITE_GUEST = "live_invite_guest"
+    LIVE_VIEWER_INVITE = "live_viewer_invite"
+    TYPE_MAX = "type_max"
+    PLACEHOLDER = "placeholder"
+    PRODUCT = "product"
+    PRODUCT_SHARE = "product_share"
+    VIDEO_CALL_EVENT = "video_call_event"
+    POLL_VOTE = "poll_vote"
+    FELIX_SHARE = "felix_share"
+    ANIMATED_MEDIA = "animated_media"
+    CTA_LINK = "cta_link"
+    VOICE_MEDIA = "voice_media"
+    STATIC_STICKER = "static_sticker"
+    AR_EFFECT = "ar_effect"
+    SELFIE_STICKER = "selfie_sticker"
+    REACTION = "reaction"
+
+
+@dataclass(kw_only=True)
+class ThreadItemActionLog(SerializableAttrs['ThreadItemActionLog']):
+    description: str
+    # TODO bold, text_attributes
+
+
+class ViewMode(SerializableEnum):
+    ONCE = "once"
+    REPLAYABLE = "replayable"
+    PERMANENT = "permanent"
+
+
+@dataclass(kw_only=True)
+class CreativeConfig(SerializableAttrs['CreativeConfig']):
+    capture_type: str
+    camera_facing: str
+    should_render_try_it_on: bool
+
+
+@dataclass(kw_only=True)
+class CreateModeAttribution(SerializableAttrs['CreateModeAttribution']):
+    type: str
+    name: str
+
+
+@dataclass(kw_only=True)
+class ImageVersion(SerializableAttrs['ImageVersion']):
+    width: int
+    height: int
+    url: str
+    estimated_scan_sizes: Optional[List[int]] = None
+
+
+@dataclass(kw_only=True)
+class ImageVersions(SerializableAttrs['ImageVersions']):
+    candidates: List[ImageVersion]
+
+
+@dataclass(kw_only=True)
+class VideoVersion(SerializableAttrs['VideoVersion']):
+    type: int
+    width: int
+    height: int
+    url: str
+    id: str
+
+
+class MediaType(SerializableEnum):
+    IMAGE = 1
+    VIDEO = 2
+    AD_MAP = 6
+    LIVE = 7
+    CAROUSEL = 8
+    LIVE_REPLAY = 9
+    COLLECTION = 10
+    AUDIO = 11
+    SHOWREEL_NATIVE = 12
+
+
+@dataclass(kw_only=True)
+class RegularMediaItem(SerializableAttrs['RegularMediaItem']):
+    id: str
+    image_versions2: Optional[ImageVersions] = None
+    video_versions: Optional[List[VideoVersion]] = None
+    original_width: int
+    original_height: int
+    media_type: MediaType
+    media_id: Optional[int] = None
+    organic_tracking_token: Optional[str] = None
+    creative_config: Optional[CreativeConfig] = None
+    create_mode_attribution: Optional[CreateModeAttribution] = None
+
+
+@dataclass(kw_only=True)
+class Caption(SerializableAttrs['Caption']):
+    pk: int
+    user_id: int
+    text: str
+    # TODO enum?
+    type: int
+    created_at: int
+    created_at_utc: int
+    content_type: str
+    # TODO enum?
+    status: str
+    bit_flags: int
+    user: BaseResponseUser
+    did_report_as_spam: bool
+    share_enabled: bool
+    media_id: int
+
+
+@dataclass(kw_only=True)
+class MediaShareItem(SerializableAttrs['MediaShareItem']):
+    taken_at: int
+    pk: int
+    id: str
+    device_timestamp: int
+    media_type: MediaType
+    code: str
+    client_cache_key: str
+    filter_type: int
+    image_versions2: ImageVersions
+    video_versions: VideoVersion
+    original_width: int
+    original_height: int
+    user: BaseResponseUser
+    can_viewer_reshare: bool
+    caption_is_edited: bool
+    comment_likes_enabled: bool
+    comment_threading_enabled: bool
+    has_more_comments: bool
+    max_num_visible_preview_comments: int
+    can_view_more_preview_comments: bool
+    comment_count: int
+    like_count: int
+    has_liked: bool
+    photo_of_you: bool
+    caption: Caption
+    can_viewer_save: bool
+    organic_tracking_token: str
+
+
+@dataclass(kw_only=True)
+class ReplayableMediaItem(SerializableAttrs['ReplayableMediaItem']):
+    view_mode: ViewMode
+    seen_count: int
+    seen_user_ids: List[int]
+    replay_expiring_at_us: Optional[Any] = None
+
+
+@dataclass(kw_only=True)
+class VisualMedia(ReplayableMediaItem, SerializableAttrs['VisualMedia']):
+    url_expire_at_secs: int
+    playback_duration_secs: int
+    media: RegularMediaItem
+
+
+@dataclass(kw_only=True)
+class AudioInfo(SerializableAttrs['AudioInfo']):
+    audio_src: str
+    duration: int
+    waveform_data: List[int]
+    waveform_sampling_frequence_hz: int
+
+
+@dataclass(kw_only=True)
+class VoiceMediaData(SerializableAttrs['VoiceMediaData']):
+    id: str
+    audio: AudioInfo
+    organic_tracking_token: str
+    user: UserIdentifier
+    # TODO enum?
+    product_type: str = "direct_audio"
+    media_type: MediaType = MediaType.AUDIO
+
+
+@dataclass(kw_only=True)
+class VoiceMediaItem(ReplayableMediaItem, SerializableAttrs['VoiceMediaItem']):
+    media: VoiceMediaData
+
+
+@dataclass(kw_only=True)
+class AnimatedMediaImage(SerializableAttrs['AnimatedMediaImage']):
+    height: str
+    mp4: str
+    mp4_size: str
+    size: str
+    url: str
+    webp: str
+    webp_size: str
+    width: str
+
+
+@dataclass(kw_only=True)
+class AnimatedMediaImages(SerializableAttrs['AnimatedMediaImages']):
+    fixed_height: Optional[AnimatedMediaImage] = None
+
+
+@dataclass(kw_only=True)
+class AnimatedMediaItem(SerializableAttrs['AnimatedMediaItem']):
+    id: str
+    is_random: str
+    is_sticker: str
+    images: AnimatedMediaImages
+
+
+@dataclass(kw_only=True)
+class ThreadItem(SerializableAttrs['ThreadItem']):
+    item_id: Optional[str] = None
+    user_id: Optional[int] = None
+    timestamp: int
+    item_type: Optional[ThreadItemType] = None
+    is_shh_mode: bool = False
+
+    text: Optional[str] = None
+    client_context: Optional[str] = None
+    show_forward_attribution: Optional[bool] = None
+    action_log: Optional[ThreadItemActionLog] = None
+
+    # These have only been observed over MQTT and not confirmed in direct_inbox
+    media: Optional[RegularMediaItem] = None
+    voice_media: Optional[VoiceMediaItem] = None
+    animated_media: Optional[AnimatedMediaItem] = None
+    visual_media: Optional[VisualMedia] = None
+    media_share: Optional[MediaShareItem] = None