1+ from __future__ import annotations
2+
13import os
2- from urllib .parse import urlparse
34import tempfile
5+ from urllib .parse import urlparse
46
57import requests
68from mastodon import Mastodon
79
8- from abstractions import Post , PostResult , SocialPoster , AdoptablePet
10+ from abstractions import AdoptablePet , Post , PostResult , SocialPoster
911from abstractions import CITY_NAME , CITY_STATE
1012
13+
14+ THREAD_SUFFIX = "\n \n More details below ⬇️"
1115MASTODON_CHARACTER_LIMIT = 500
1216TRUNCATION_SUFFIX = "..."
17+ MAX_REPLIES = 5
1318
1419
1520class PosterMastodon (SocialPoster ):
16- def __init__ (self ):
21+ def __init__ (self ) -> None :
1722 raw_token = os .environ .get ("MASTODON_TOKEN" )
1823 self .token = raw_token .strip () if raw_token else None
1924 self .api_base_url = "https://mastodon.social"
20- self ._session = None
25+ self ._session : Mastodon | None = None
2126 self ._is_available = bool (self .token )
22- self ._auth_error = None
27+ self ._auth_error : str | None = None
2328
2429 @property
2530 def platform_name (self ) -> str :
@@ -61,69 +66,151 @@ def publish(self, post: Post) -> PostResult:
6166 else f"Mastodon authentication failed: { self ._auth_error } "
6267 ),
6368 )
64-
69+
6570 image_path = None
71+
6672 try :
6773 image_path = self ._download_image (post .image_url )
74+
6875 media = self ._session .media_post (
6976 image_path ,
7077 description = post .alt_text or "Photo of an adoptable pet" ,
7178 )
72- status = self . _session . status_post (
73- self ._format_caption (post ),
74- media_ids = [ media ["id" ]],
75- )
79+
80+ main_caption , replies = self ._format_caption_thread (post )
81+ status = self . _post_thread ( main_caption , replies , media ["id" ])
82+
7683 return PostResult (
7784 success = True ,
7885 post_id = str (status ["id" ]),
7986 post_url = status .get ("url" ),
8087 )
88+
8189 except Exception as exc :
8290 return PostResult (success = False , error_message = str (exc ))
91+
8392 finally :
8493 self ._session = None
8594 if image_path and os .path .exists (image_path ):
8695 os .unlink (image_path )
8796
88- def _format_caption (self , post : Post ) -> str :
89- tags = " " .join (f"#{ tag } " for tag in post .tags if tag )
90- tag_suffix = f"\n \n { tags } " if tags else ""
91- available_text_length = MASTODON_CHARACTER_LIMIT - len (tag_suffix )
97+ def _post_thread (
98+ self ,
99+ main_caption : str ,
100+ replies : list [str ],
101+ media_id : str ,
102+ ) -> dict :
103+ status = self ._session .status_post (
104+ main_caption ,
105+ media_ids = [media_id ],
106+ )
107+
108+ root_status_id = status ["id" ]
109+
110+ for reply_text in replies :
111+ self ._session .status_post (
112+ reply_text ,
113+ in_reply_to_id = root_status_id ,
114+ )
92115
93- if available_text_length <= len (TRUNCATION_SUFFIX ):
94- return (tag_suffix [- MASTODON_CHARACTER_LIMIT :]).strip ()
116+ return status
95117
118+ def _format_caption_thread (self , post : Post ) -> tuple [str , list [str ]]:
96119 caption_text = post .text .strip ()
97- if len (caption_text ) > available_text_length :
98- caption_text = caption_text [: available_text_length - len (TRUNCATION_SUFFIX )].rstrip ()
99- caption_text = f"{ caption_text } { TRUNCATION_SUFFIX } "
120+ tag_suffix = self ._format_tag_suffix (post .tags )
121+
122+ if self ._fits_single_post (caption_text , tag_suffix ):
123+ return f"{ caption_text } { tag_suffix } " , []
124+
125+ main_limit = self ._main_caption_limit (tag_suffix )
126+
127+ if main_limit <= 0 :
128+ raise ValueError ("Tags are too long to fit in a Mastodon post." )
129+
130+ main_text , overflow = self ._safe_truncate (caption_text , main_limit )
131+ replies = self ._split_reply_chunks (overflow )
132+
133+ main_caption = (
134+ f"{ main_text } "
135+ f"{ TRUNCATION_SUFFIX } "
136+ f"{ THREAD_SUFFIX } "
137+ f"{ tag_suffix } "
138+ )
139+
140+ return main_caption , replies
141+
142+ @staticmethod
143+ def _fits_single_post (caption_text : str , tag_suffix : str ) -> bool :
144+ return len (caption_text ) + len (tag_suffix ) <= MASTODON_CHARACTER_LIMIT
145+
146+ @staticmethod
147+ def _format_tag_suffix (tags : list [str ]) -> str :
148+ clean_tags = [tag for tag in tags if tag ]
149+ tag_text = " " .join (f"#{ tag } " for tag in clean_tags )
150+ return f"\n \n { tag_text } " if tag_text else ""
151+
152+ @staticmethod
153+ def _main_caption_limit (tag_suffix : str ) -> int :
154+ return (
155+ MASTODON_CHARACTER_LIMIT
156+ - len (tag_suffix )
157+ - len (THREAD_SUFFIX )
158+ - len (TRUNCATION_SUFFIX )
159+ )
160+
161+ def _split_reply_chunks (self , text : str ) -> list [str ]:
162+ chunks = []
163+ remaining = text .strip ()
164+
165+ while remaining and len (chunks ) < MAX_REPLIES :
166+ chunk , remaining = self ._safe_truncate (
167+ remaining ,
168+ MASTODON_CHARACTER_LIMIT ,
169+ )
170+ chunks .append (chunk )
100171
101- return f"{ caption_text } { tag_suffix } "
172+ if remaining and chunks :
173+ cutoff = MASTODON_CHARACTER_LIMIT - len (TRUNCATION_SUFFIX )
174+ last_chunk , _ = self ._safe_truncate (chunks [- 1 ], cutoff )
175+ chunks [- 1 ] = f"{ last_chunk } { TRUNCATION_SUFFIX } "
176+
177+ return chunks
102178
103179 def _download_image (self , image_url : str ) -> str :
104180 parsed_url = urlparse (image_url )
105181 ext = os .path .splitext (parsed_url .path )[1 ] or ".jpg"
182+
106183 with tempfile .NamedTemporaryFile (delete = False , suffix = ext ) as tmp :
107- response = requests .get (image_url , stream = True , timeout = 20 )
108- response .raise_for_status ()
109- for chunk in response .iter_content (chunk_size = 1024 * 128 ):
110- if chunk :
111- tmp .write (chunk )
184+ with requests .get (image_url , stream = True , timeout = 20 ) as response :
185+ response .raise_for_status ()
186+
187+ for chunk in response .iter_content (chunk_size = 1024 * 128 ):
188+ if chunk :
189+ tmp .write (chunk )
190+
112191 return tmp .name
113192
114- # rearrange so that link is at top
115- # need to test
116- def format_post (self , pet :AdoptablePet ) -> Post :
117- """
118- Create a Post from an AdoptablePet.
193+ @staticmethod
194+ def _safe_truncate (text : str , limit : int ) -> tuple [str , str ]:
195+ if len (text ) <= limit :
196+ return text , ""
197+
198+ cut = text .rfind (" " , 0 , limit )
199+
200+ if cut == - 1 :
201+ cut = limit
119202
120- Override this method to customize post formatting for specific platforms.
121- """
122- text = f"Meet { pet .name } ! This adorable { pet .breed } { pet .species } is looking for a forever home in { pet .location } ."
203+ return text [:cut ].rstrip (), text [cut :].strip ()
204+
205+ def format_post (self , pet : AdoptablePet ) -> Post :
206+ text = (
207+ f"Meet { pet .name } ! This adorable { pet .breed } { pet .species } "
208+ f"is looking for a forever home in { pet .location } ."
209+ )
123210
124211 if pet .adoption_url :
125- text += f" Adopt { pet .name } : { pet .adoption_url } "
126-
212+ text += f" Adopt { pet .name } : { pet .adoption_url } "
213+
127214 if pet .description :
128215 text += f"\n \n { pet .description } "
129216
@@ -135,7 +222,10 @@ def format_post(self, pet:AdoptablePet) -> Post:
135222 text = text ,
136223 image_url = pet .image_url ,
137224 link = pet .adoption_url ,
138- alt_text = f"Photo of { pet .name } , a { pet .breed } { pet .species } available for adoption" ,
225+ alt_text = (
226+ f"Photo of { pet .name } , a { pet .breed } { pet .species } "
227+ "available for adoption"
228+ ),
139229 tags = [
140230 "adoptdontshop" ,
141231 "rescue" ,
0 commit comments