diff --git a/bsky_post.py b/bsky_post.py index 0277712..b128092 100644 --- a/bsky_post.py +++ b/bsky_post.py @@ -2,12 +2,10 @@ """ bsky_post.py — Post text + optional image or video to Bluesky/federated PDS. -Usage examples: - python3 bsky_post.py "DIVENDRES!!!!" --video friday.mp4 --username you --password app-pass --service https://eurosky.social - python3 bsky_post.py "Dijous!!!!" --image thursday.jpg --username you --password app-pass - python3 bsky_post.py "Bon dia!" --username you --password app-pass - python3 bsky_post.py "Bon dia!" --image photo.jpg --lang ca --username you --password app-pass - python3 bsky_post.py "Long video!" --video clip.mp4 --username you --password app-pass --allow-pds-video-fallback +Examples: + python3 bsky_post.py "DIVENDRES!!!!" --video media/divendres.mp4 --username you --password app-pass --service https://eurosky.social + python3 bsky_post.py "Dijous!!!!" --image media/dijous.jpg --username you --password app-pass --service https://eurosky.social + python3 bsky_post.py "Bon dia!" --username you --password app-pass --service https://eurosky.social """ import argparse @@ -22,7 +20,7 @@ from dataclasses import dataclass from urllib.parse import urlparse import requests -from atproto import Client, client_utils, models +from atproto import Client, models # ============================================================ @@ -47,47 +45,6 @@ def setup_logging() -> None: ) -# ============================================================ -# Text builder -# ============================================================ -def make_rich(content: str): - text_builder = client_utils.TextBuilder() - content = content.strip() - lines = content.splitlines() - - for line_idx, line in enumerate(lines): - if not line.strip(): - if line_idx < len(lines) - 1: - text_builder.text("\n") - continue - - words = line.split(" ") - for i, word in enumerate(words): - if not word: - if i < len(words) - 1: - text_builder.text(" ") - continue - - if word.startswith("http://") or word.startswith("https://"): - text_builder.link(word, word) - elif word.startswith("#") and len(word) > 1: - tag_name = word[1:].rstrip(".,;:!?)'\"") - if tag_name: - text_builder.tag(word, tag_name) - else: - text_builder.text(word) - else: - text_builder.text(word) - - if i < len(words) - 1: - text_builder.text(" ") - - if line_idx < len(lines) - 1: - text_builder.text("\n") - - return text_builder - - # ============================================================ # Error helpers # ============================================================ @@ -147,27 +104,7 @@ def is_transient_error(error_obj) -> bool: def get_rate_limit_wait_seconds(error_obj, default_delay: float, max_delay: float) -> float: try: now_ts = int(time.time()) - headers = getattr(error_obj, "headers", None) or {} - retry_after = headers.get("retry-after") or headers.get("Retry-After") - if retry_after: - return min(max(float(retry_after), 1.0), max_delay) - - x_after = headers.get("x-ratelimit-after") or headers.get("X-RateLimit-After") - if x_after: - return min(max(float(x_after), 1.0), max_delay) - - reset_value = headers.get("ratelimit-reset") or headers.get("RateLimit-Reset") - if reset_value: - wait_seconds = max(float(reset_value) - now_ts + 1.0, default_delay) - return min(wait_seconds, max_delay) - except Exception: - pass - - try: - response = getattr(error_obj, "response", None) - headers = getattr(response, "headers", None) or {} - now_ts = int(time.time()) retry_after = headers.get("retry-after") or headers.get("Retry-After") if retry_after: @@ -232,36 +169,25 @@ def login_with_backoff( if is_rate_limited_error(e): if attempt < max_attempts: wait = get_rate_limit_wait_seconds(e, default_delay=base_delay, max_delay=max_delay) - wait = wait + random.uniform(0, jitter) - logging.warning( - f"⏳ Rate-limited on login (attempt {attempt}/{max_attempts}). " - f"Retrying in {wait:.1f}s..." - ) + wait += random.uniform(0, jitter) + logging.warning(f"⏳ Rate-limited. Retrying in {wait:.1f}s...") time.sleep(wait) continue - logging.error("❌ Exhausted login retries due to rate limiting.") return False if is_network_error(e) or is_transient_error(e): if attempt < max_attempts: wait = min(base_delay * attempt, max_delay) + random.uniform(0, jitter) - logging.warning( - f"⏳ Transient login error (attempt {attempt}/{max_attempts}). " - f"Retrying in {wait:.1f}s..." - ) + logging.warning(f"⏳ Transient error. Retrying in {wait:.1f}s...") time.sleep(wait) continue - logging.error("❌ Exhausted login retries after transient/network errors.") return False if attempt < max_attempts: wait = min(base_delay * attempt, max_delay) + random.uniform(0, jitter) - logging.warning( - f"⏳ Unknown login error (attempt {attempt}/{max_attempts}). " - f"Retrying in {wait:.1f}s..." - ) + logging.warning(f"⏳ Unknown login error. Retrying in {wait:.1f}s...") time.sleep(wait) continue @@ -289,10 +215,9 @@ def detect_mime_type(path: str) -> str: return fallbacks.get(ext, "application/octet-stream") -def wait_with_heartbeat(total_seconds: float, label: str = "indexing") -> None: +def wait_with_heartbeat(total_seconds: float, label: str = "processing") -> None: if total_seconds <= 0: return - logging.info(f"⏳ Waiting {total_seconds:.0f}s for {label}...") remaining = total_seconds while remaining > 0: @@ -311,6 +236,16 @@ def pds_did_from_service_url(service_url: str) -> str: return f"did:web:{host}" +def model_to_dict(obj): + if obj is None: + return None + if hasattr(obj, "model_dump"): + return obj.model_dump(by_alias=True, exclude_none=True) + if hasattr(obj, "dict"): + return obj.dict(by_alias=True, exclude_none=True) + return obj + + # ============================================================ # Media upload — Image # ============================================================ @@ -320,6 +255,10 @@ def upload_image( alt_text: str = "", ) -> models.AppBskyEmbedImages.Image | None: try: + if not os.path.exists(image_path): + logging.error(f"❌ Image file not found: {image_path}") + return None + mime = detect_mime_type(image_path) with open(image_path, "rb") as f: data = f.read() @@ -336,7 +275,7 @@ def upload_image( # ============================================================ -# Media upload — Video (PDS direct fallback only) +# Media upload — Video via PDS direct fallback # ============================================================ def upload_video_via_pds( client: Client, @@ -346,7 +285,7 @@ def upload_video_via_pds( ) -> models.AppBskyEmbedVideo.Main | None: """ Direct upload to home PDS via upload_blob. - Kept only as optional fallback; playback can be unreliable in clients. + Fallback only. Can be less reliable for playback in clients. """ try: if not os.path.exists(video_path): @@ -373,7 +312,7 @@ def upload_video_via_pds( # ============================================================ -# Media upload — Video (video.bsky.app primary) +# Media upload — Video via video.bsky.app (primary) # ============================================================ def _extract_service_auth_token(upload_auth) -> str | None: token = getattr(upload_auth, "token", None) @@ -393,9 +332,9 @@ def upload_video_via_bsky_service( """ Upload via centralized video.bsky.app service. - IMPORTANT FIX: - getServiceAuth(aud=...) must use the user's PDS DID (e.g. did:web:eurosky.social), - not did:web:video.bsky.app. + Critical compatibility fixes: + - aud must be user's PDS DID (e.g. did:web:eurosky.social) + - lxm must be com.atproto.repo.uploadBlob """ try: if not os.path.exists(video_path): @@ -411,19 +350,19 @@ def upload_video_via_bsky_service( VIDEO_HOST = "https://video.bsky.app" pds_did = pds_did_from_service_url(service_url) - # Robust for different atproto versions + # Some atproto versions prefer typed params, others accept dict try: params = models.ComAtprotoServerGetServiceAuth.Params( - aud=pds_did, # <-- critical fix - lxm="app.bsky.video.uploadVideo", + aud=pds_did, + lxm="com.atproto.repo.uploadBlob", # <-- critical fix exp=int(time.time()) + 60 * 30, ) upload_auth = client.com.atproto.server.get_service_auth(params) except Exception: upload_auth = client.com.atproto.server.get_service_auth( { - "aud": pds_did, # <-- critical fix - "lxm": "app.bsky.video.uploadVideo", + "aud": pds_did, + "lxm": "com.atproto.repo.uploadBlob", # <-- critical fix "exp": int(time.time()) + 60 * 30, } ) @@ -505,10 +444,7 @@ def upload_video_smart( settle_delay_seconds: float = 30.0, allow_pds_video_fallback: bool = False, ) -> models.AppBskyEmbedVideo.Main | None: - logging.info( - f"🌍 PDS ({service_url}). Trying video.bsky.app first for playback reliability." - ) - + logging.info(f"🌍 PDS ({service_url}). Trying video.bsky.app first.") embed = upload_video_via_bsky_service( client=client, video_path=video_path, @@ -519,10 +455,7 @@ def upload_video_smart( return embed if allow_pds_video_fallback: - logging.warning( - "⚠️ video.bsky.app failed; trying direct PDS fallback " - "(may be unplayable in some clients)." - ) + logging.warning("⚠️ video.bsky.app failed; trying direct PDS fallback.") return upload_video_via_pds( client=client, video_path=video_path, @@ -530,15 +463,12 @@ def upload_video_smart( settle_delay_seconds=settle_delay_seconds, ) - logging.error( - "❌ video.bsky.app failed. Not posting unreliable direct-PDS video. " - "Use --allow-pds-video-fallback to override." - ) + logging.error("❌ video.bsky.app failed. Not using direct fallback unless enabled.") return None # ============================================================ -# Post +# Post creation (explicit record to guarantee text string) # ============================================================ def post_to_bsky( client: Client, @@ -553,7 +483,6 @@ def post_to_bsky( ) -> bool: post_text = text.strip() - # Allow empty text only if media exists if not post_text and not image_path and not video_path: logging.error("❌ Empty post text with no media is not allowed.") return False @@ -563,7 +492,7 @@ def post_to_bsky( if video_path: logging.info(f"🎬 Preparing video upload: {video_path}") - video_embed = upload_video_smart( + embed_obj = upload_video_smart( client=client, video_path=video_path, service_url=service_url, @@ -571,10 +500,9 @@ def post_to_bsky( settle_delay_seconds=video_settle_delay, allow_pds_video_fallback=allow_pds_video_fallback, ) - if not video_embed: + if not embed_obj: logging.error("❌ Aborting post: video upload/processing failed.") return False - embed_obj = video_embed elif image_path: image = upload_image(client, image_path, alt_text=alt_text) @@ -583,10 +511,9 @@ def post_to_bsky( return False embed_obj = models.AppBskyEmbedImages.Main(images=[image]) - # Build record explicitly (most reliable) record = { "$type": "app.bsky.feed.post", - "text": post_text, # <-- guaranteed string + "text": post_text, # guaranteed plain string "createdAt": time.strftime("%Y-%m-%dT%H:%M:%S.000Z", time.gmtime()), } @@ -594,23 +521,27 @@ def post_to_bsky( record["langs"] = langs if embed_obj is not None: - # atproto models -> plain dict - if hasattr(embed_obj, "model_dump"): - record["embed"] = embed_obj.model_dump(by_alias=True, exclude_none=True) - elif hasattr(embed_obj, "dict"): - record["embed"] = embed_obj.dict(by_alias=True, exclude_none=True) - else: - record["embed"] = embed_obj + record["embed"] = model_to_dict(embed_obj) logging.info(f"🧾 Final record text={record.get('text')!r}, has_embed={'embed' in record}") - resp = client.com.atproto.repo.create_record( - models.ComAtprotoRepoCreateRecord.Data( - repo=client.me.did, - collection="app.bsky.feed.post", - record=record, + # typed first, dict fallback for compatibility + try: + resp = client.com.atproto.repo.create_record( + models.ComAtprotoRepoCreateRecord.Data( + repo=client.me.did, + collection="app.bsky.feed.post", + record=record, + ) + ) + except Exception: + resp = client.com.atproto.repo.create_record( + { + "repo": client.me.did, + "collection": "app.bsky.feed.post", + "record": record, + } ) - ) uri = getattr(resp, "uri", None) or (resp.get("uri") if isinstance(resp, dict) else None) logging.info(f"✅ Post published! URI: {uri}") @@ -620,33 +551,32 @@ def post_to_bsky( logging.error(f"❌ Failed to send post: {repr(e)}") return False + # ============================================================ # CLI # ============================================================ def main(): setup_logging() - parser = argparse.ArgumentParser( - description="Post text + optional image or video to Bluesky/federated PDS." - ) + parser = argparse.ArgumentParser(description="Post text + optional image/video to Bluesky/federated PDS.") parser.add_argument("text", help="Post text content") parser.add_argument("--username", required=True, help="Bluesky handle or email") parser.add_argument("--password", required=True, help="Bluesky app password") - parser.add_argument("--service", default="https://bsky.social", help="Bluesky PDS URL") + parser.add_argument("--service", default="https://bsky.social", help="PDS URL") parser.add_argument("--lang", default="ca", help="Comma-separated language codes (e.g. ca,es)") - parser.add_argument("--image", default=None, help="Path to image file to attach") - parser.add_argument("--video", default=None, help="Path to video file to attach") + parser.add_argument("--image", default=None, help="Path to image file") + parser.add_argument("--video", default=None, help="Path to video file") parser.add_argument("--alt", default="", help="Alt text for media") parser.add_argument( "--video-settle-delay", type=float, default=30.0, - help="Seconds to wait after direct-PDS fallback upload before posting (default: 30).", + help="Seconds to wait after direct-PDS fallback upload before posting.", ) parser.add_argument( "--allow-pds-video-fallback", action="store_true", - help="Allow direct-PDS fallback if video.bsky.app fails (less reliable playback).", + help="Allow direct PDS video fallback if video.bsky.app fails.", ) args = parser.parse_args() @@ -656,6 +586,7 @@ def main(): sys.exit(1) client = Client(base_url=args.service) + success = login_with_backoff( client=client, username=args.username,