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 .login_simulate import LoginSimulateAPI
 from .upload import UploadAPI
 from .upload import UploadAPI
 
 
 
 
-class AndroidAPI(DirectInboxAPI, LoginSimulateAPI, UploadAPI):
+class AndroidAPI(ThreadAPI, LoginSimulateAPI, UploadAPI):
     pass
     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,
 from .account import (CurrentUser, EntityText, HDProfilePictureVersion, CurrentUserResponse,
                       FriendshipStatus, UserIdentifier, BaseFullResponseUser, BaseResponseUser,
                       FriendshipStatus, UserIdentifier, BaseFullResponseUser, BaseResponseUser,
                       ProfileEditParams)
                       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 .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,
 from .mqtt import (Operation, ThreadAction, ReactionStatus, TypingStatus, CommandResponsePayload,
                    CommandResponse, IrisPayloadData, IrisPayload, MessageSyncMessage,
                    CommandResponse, IrisPayloadData, IrisPayload, MessageSyncMessage,
                    MessageSyncEvent, PubsubBasePayload, PubsubPublishMetadata, PubsubPayloadData,
                    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
 # 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/>.
 # 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 attr import dataclass
 from mautrix.types import SerializableAttrs
 from mautrix.types import SerializableAttrs
 
 
-from .account import BaseResponseUser
-from .thread import ThreadItem
+from .thread import Thread, ThreadUser
 
 
 
 
 @dataclass
 @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_timestamp_seconds: int
     cursor_thread_v2_id: int
     cursor_thread_v2_id: int
 
 
 
 
 @dataclass
 @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
     has_older: bool
     unseen_count: int
     unseen_count: int
     unseen_count_ts: int
     unseen_count_ts: int
-    prev_cursor: DirectInboxCursor
-    next_cursor: DirectInboxCursor
+    prev_cursor: DMInboxCursor
+    next_cursor: DMInboxCursor
     blended_inbox_enabled: bool
     blended_inbox_enabled: bool
+    newest_cursor: Optional[str] = None
+    oldest_cursor: Optional[str] = None
 
 
 
 
 @dataclass
 @dataclass
-class DirectInboxResponse(SerializableAttrs['DirectInboxFeedResponse']):
+class DMInboxResponse(SerializableAttrs['DMInboxResponse']):
     status: str
     status: str
     seq_id: int
     seq_id: int
     snapshot_at_ms: int
     snapshot_at_ms: int
     pending_requests_total: int
     pending_requests_total: int
     has_pending_top_requests: bool
     has_pending_top_requests: bool
-    viewer: DirectInboxUser
-    inbox: DirectInbox
+    viewer: ThreadUser
+    inbox: DMInbox
     # TODO type
     # TODO type
     most_recent_inviter: Any = None
     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']):
 class MessageSyncMessage(ThreadItem, SerializableAttrs['MessageSyncMessage']):
     path: str
     path: str
     op: Operation = Operation.ADD
     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
     admin_user_ids: Optional[int] = None
     approval_required_for_new_members: Optional[bool] = None
     approval_required_for_new_members: Optional[bool] = None
     participants: Optional[Dict[str, str]] = None
     participants: Optional[Dict[str, str]] = None
     reactions: Optional[dict] = None
     reactions: Optional[dict] = None
+    thread_id: Optional[str] = None
 
 
 
 
 @dataclass(kw_only=True)
 @dataclass(kw_only=True)

+ 64 - 243
mauigpapi/types/thread.py

@@ -16,254 +16,75 @@
 from typing import List, Any, Dict, Optional
 from typing import List, Any, Dict, Optional
 
 
 from attr import dataclass
 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
     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