Browse Source

Add thread iter methods and reorganize more types

Tulir Asokan 4 years ago
parent
commit
fb0dcdcbfa

+ 2 - 2
mauigpapi/http/api.py

@@ -1,7 +1,7 @@
-from .direct_inbox import DirectInboxAPI
+from .thread import ThreadAPI
 from .login_simulate import LoginSimulateAPI
 from .upload import UploadAPI
 
 
-class AndroidAPI(DirectInboxAPI, LoginSimulateAPI, UploadAPI):
+class AndroidAPI(ThreadAPI, LoginSimulateAPI, UploadAPI):
     pass

+ 0 - 35
mauigpapi/http/direct_inbox.py

@@ -1,35 +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 Optional
-
-from .base import BaseAndroidAPI
-from ..types import DirectInboxResponse
-
-
-class DirectInboxAPI(BaseAndroidAPI):
-    async def direct_inbox(self, cursor: Optional[str] = None, seq_id: Optional[str] = None,
-                           thread_message_limit: int = 10, limit: int = 20) -> DirectInboxResponse:
-        query = {
-            "visual_message_return_type": "unseen",
-            "cursor": cursor,
-            "direction": "older" if cursor else None,
-            "seq_id": seq_id,
-            "thread_message_limit": thread_message_limit,
-            "persistentBadging": "true",
-            "limit": limit,
-        }
-        return await self.std_http_get("/api/v1/direct_v2/inbox/", query=query,
-                                       response_type=DirectInboxResponse)

+ 72 - 0
mauigpapi/http/thread.py

@@ -0,0 +1,72 @@
+# 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 Optional, AsyncIterable
+
+from .base import BaseAndroidAPI
+from ..types import DMInboxResponse, DMThreadResponse, Thread, ThreadItem
+
+
+class ThreadAPI(BaseAndroidAPI):
+    async def get_inbox(self, cursor: Optional[str] = None, seq_id: Optional[str] = None,
+                        message_limit: int = 10, limit: int = 20, pending: bool = False,
+                        direction: str = "older") -> DMInboxResponse:
+        query = {
+            "visual_message_return_type": "unseen",
+            "cursor": cursor,
+            "direction": direction if cursor else None,
+            "seq_id": seq_id,
+            "thread_message_limit": message_limit,
+            "persistentBadging": "true",
+            "limit": limit,
+        }
+        inbox_type = "pending_inbox" if pending else "inbox"
+        return await self.std_http_get(f"/api/v1/direct_v2/{inbox_type}/", query=query,
+                                       response_type=DMInboxResponse)
+
+    async def iter_inbox(self, cursor: Optional[str] = None, seq_id: Optional[str] = None,
+                         message_limit: int = 10) -> AsyncIterable[Thread]:
+        has_more = True
+        while has_more:
+            resp = await self.get_inbox(message_limit=message_limit, cursor=cursor, seq_id=seq_id)
+            seq_id = resp.seq_id
+            cursor = resp.inbox.prev_cursor
+            has_more = resp.inbox.has_older
+            for thread in resp.inbox.threads:
+                yield thread
+
+    async def get_thread(self, thread_id: str,  cursor: Optional[str] = None, limit: int = 10,
+                         direction: str = "older", seq_id: Optional[int] = None
+                         ) -> DMThreadResponse:
+        query = {
+            "visual_message_return_type": "unseen",
+            "cursor": cursor,
+            "direction": direction,
+            "seq_id": seq_id,
+            "limit": limit,
+        }
+        return await self.std_http_get(f"/api/v1/direct_v2/threads/{thread_id}/", query=query,
+                                       response_type=DMThreadResponse)
+
+    async def iter_thread(self, thread_id: str, seq_id: Optional[int] = None,
+                          cursor: Optional[str] = None) -> AsyncIterable[ThreadItem]:
+        has_more = True
+        while has_more:
+            resp = await self.get_thread(thread_id, seq_id=seq_id, cursor=cursor)
+            cursor = resp.thread.oldest_cursor
+            has_more = resp.thread.has_older
+            for item in resp.thread.items:
+                yield item
+

+ 7 - 7
mauigpapi/types/__init__.py

@@ -6,14 +6,14 @@ from .login import LoginResponseUser, LoginResponseNametag, LoginResponse, Logou
 from .account import (CurrentUser, EntityText, HDProfilePictureVersion, CurrentUserResponse,
                       FriendshipStatus, UserIdentifier, BaseFullResponseUser, BaseResponseUser,
                       ProfileEditParams)
-from .direct_inbox import (DirectInboxResponse, DirectInboxUser, DirectInboxCursor, DirectInbox,
-                           DirectInboxThreadTheme, DirectInboxThread, UserLastSeenAt)
+from .direct_inbox import DMInboxResponse, DMInboxCursor, DMInbox, DMThreadResponse
 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 .thread_item import (ThreadItemType, ThreadItemActionLog, ViewMode, CreativeConfig, MediaType,
+                          CreateModeAttribution, ImageVersion, ImageVersions, VisualMedia, Caption,
+                          RegularMediaItem, MediaShareItem, ReplayableMediaItem, VideoVersion,
+                          AudioInfo, VoiceMediaItem, AnimatedMediaImage, AnimatedMediaImages,
+                          AnimatedMediaItem, ThreadItem, VoiceMediaData)
+from .thread import Thread, ThreadUser, ThreadItem, ThreadUserLastSeenAt, ThreadTheme
 from .mqtt import (Operation, ThreadAction, ReactionStatus, TypingStatus, CommandResponsePayload,
                    CommandResponse, IrisPayloadData, IrisPayload, MessageSyncMessage,
                    MessageSyncEvent, PubsubBasePayload, PubsubPublishMetadata, PubsubPayloadData,

+ 18 - 75
mauigpapi/types/direct_inbox.py

@@ -13,104 +13,47 @@
 #
 # 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 typing import List, Any, Optional
 
 from attr import dataclass
 from mautrix.types import SerializableAttrs
 
-from .account import BaseResponseUser
-from .thread import ThreadItem
+from .thread import Thread, ThreadUser
 
 
 @dataclass
-class DirectInboxUser(BaseResponseUser, SerializableAttrs['DirectInboxViewer']):
-    interop_messaging_user_fbid: int
-    is_using_unified_inbox_for_direct: bool
-
-
-@dataclass
-class DirectInboxCursor(SerializableAttrs['DirectInboxCursor']):
+class DMInboxCursor(SerializableAttrs['DMInboxCursor']):
     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]
+class DMInbox(SerializableAttrs['DMInbox']):
+    threads: List[Thread]
     has_older: bool
     unseen_count: int
     unseen_count_ts: int
-    prev_cursor: DirectInboxCursor
-    next_cursor: DirectInboxCursor
+    prev_cursor: DMInboxCursor
+    next_cursor: DMInboxCursor
     blended_inbox_enabled: bool
+    newest_cursor: Optional[str] = None
+    oldest_cursor: Optional[str] = None
 
 
 @dataclass
-class DirectInboxResponse(SerializableAttrs['DirectInboxFeedResponse']):
+class DMInboxResponse(SerializableAttrs['DMInboxResponse']):
     status: str
     seq_id: int
     snapshot_at_ms: int
     pending_requests_total: int
     has_pending_top_requests: bool
-    viewer: DirectInboxUser
-    inbox: DirectInbox
+    viewer: ThreadUser
+    inbox: DMInbox
     # TODO type
     most_recent_inviter: Any = None
+
+
+@dataclass
+class DMThreadResponse(SerializableAttrs['DMThreadResponse']):
+    thread: Thread
+    status: str

+ 3 - 1
mauigpapi/types/mqtt.py

@@ -88,11 +88,13 @@ class IrisPayload(SerializableAttrs['IrisPayload']):
 class MessageSyncMessage(ThreadItem, SerializableAttrs['MessageSyncMessage']):
     path: str
     op: Operation = Operation.ADD
-    # TODO some or all of these might be in direct_inbox too
+
+    # These come from parsing the path
     admin_user_ids: Optional[int] = None
     approval_required_for_new_members: Optional[bool] = None
     participants: Optional[Dict[str, str]] = None
     reactions: Optional[dict] = None
+    thread_id: Optional[str] = None
 
 
 @dataclass(kw_only=True)

+ 64 - 243
mauigpapi/types/thread.py

@@ -16,254 +16,75 @@
 from typing import List, Any, Dict, Optional
 
 from attr import dataclass
-from mautrix.types import SerializableAttrs, SerializableEnum
+from mautrix.types import SerializableAttrs
 
-from .account import BaseResponseUser, UserIdentifier
+from .account import BaseResponseUser
+from .thread_item import ThreadItem
 
 
-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
+class ThreadUser(BaseResponseUser, SerializableAttrs['ThreadUser']):
+    interop_messaging_user_fbid: int
+    is_using_unified_inbox_for_direct: bool
 
 
-@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
+@dataclass
+class ThreadTheme(SerializableAttrs['ThreadTheme']):
     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
+@dataclass
+class ThreadUserLastSeenAt(SerializableAttrs['UserLastSeenAt']):
+    timestamp: str
+    item_id: str
+    shh_seen_state: Dict[str, Any]
+
+
+@dataclass
+class Thread(SerializableAttrs['Thread']):
+    thread_id: str
+    thread_v2_id: str
+
+    users: List[ThreadUser]
+    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: ThreadTheme
+    last_seen_at: Dict[int, ThreadUserLastSeenAt]
+
+    newest_cursor: str
+    oldest_cursor: str
+    next_cursor: str
+    prev_cursor: str
+    last_permanent_item: ThreadItem
+    items: List[ThreadItem]
+
+    # These might only be in single thread requests and not inbox
+    valued_request: Optional[bool] = None
+    pending_score: Optional[bool] = None

+ 269 - 0
mauigpapi/types/thread_item.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