diff --git a/bsky_post.py b/bsky_post.py new file mode 100644 index 0000000..ef9e6c4 --- /dev/null +++ b/bsky_post.py @@ -0,0 +1,314 @@ +#!/usr/bin/env python3 +""" +bsky_post.py — Post text + optional image or video to a Bluesky instance. + +Usage examples: + python3 bsky_post.py "DIVENDRES!!!!" --video friday.mp4 + python3 bsky_post.py "Dijous!!!!" --image thursday.jpg + python3 bsky_post.py "Bon dia!" + python3 bsky_post.py "Bon dia!" --image photo.jpg --lang ca +""" + +import argparse +import logging +import mimetypes +import os +import sys +import time +import random +import re + +from atproto import Client, client_utils, models + + +# ============================================================ +# Logging +# ============================================================ +def setup_logging() -> None: + logging.basicConfig( + format="%(asctime)s %(message)s", + level=logging.INFO, + stream=sys.stdout, + ) + + +# ============================================================ +# 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 +# ============================================================ +def is_rate_limited_error(e) -> bool: + text = repr(e) + return any(s in text for s in ["429", "RateLimitExceeded", "Too Many Requests"]) + + +def is_auth_error(e) -> bool: + text = repr(e).lower() + return any(s in text for s in ["401", "403", "invalid identifier or password", + "authenticationrequired", "invalidtoken"]) + + +def is_network_error(e) -> bool: + text = repr(e) + return any(s in text for s in ["ConnectError", "RemoteProtocolError", "ReadTimeout", + "WriteTimeout", "TimeoutException", "503", "502", "504", + "ConnectionResetError"]) + + +def is_timeout_error(e) -> bool: + text = repr(e) + return any(s in text for s in ["InvokeTimeoutError", "ReadTimeout", + "WriteTimeout", "TimeoutException"]) + + +# ============================================================ +# Login with backoff (same pattern as rss2bsky / twitter bot) +# ============================================================ +def login_with_backoff( + client: Client, + username: str, + password: str, + service_url: str, + max_attempts: int = 5, + base_delay: float = 2.0, + max_delay: float = 120.0, + jitter: float = 1.5, +) -> bool: + for attempt in range(1, max_attempts + 1): + try: + logging.info( + f"🔐 Login attempt {attempt}/{max_attempts} → {service_url} as {username}" + ) + client.login(username, password) + logging.info("✅ Login successful.") + return True + + except Exception as e: + logging.exception("❌ Login exception") + + if is_rate_limited_error(e): + if attempt < max_attempts: + delay = min(base_delay * (2 ** (attempt - 1)), max_delay) + random.uniform(0, jitter) + logging.warning(f"⏳ Rate-limited on login. Retrying in {delay:.1f}s...") + time.sleep(delay) + continue + logging.error("❌ Login rate-limited and retries exhausted.") + return False + + if is_auth_error(e): + logging.error("❌ Bad credentials. Check handle/password.") + return False + + if attempt < max_attempts and (is_network_error(e) or is_timeout_error(e)): + delay = min(base_delay * attempt, max_delay) + random.uniform(0, jitter) + logging.warning(f"⏳ Transient login error. Retrying in {delay:.1f}s...") + time.sleep(delay) + continue + + if attempt < max_attempts: + delay = min(base_delay * attempt, max_delay) + random.uniform(0, jitter) + logging.warning(f"⏳ Unknown login error. Retrying in {delay:.1f}s...") + time.sleep(delay) + continue + + return False + + return False + + +# ============================================================ +# Media upload +# ============================================================ +def detect_mime_type(path: str) -> str: + mime, _ = mimetypes.guess_type(path) + if mime: + return mime + ext = os.path.splitext(path)[1].lower() + fallbacks = { + ".jpg": "image/jpeg", ".jpeg": "image/jpeg", + ".png": "image/png", ".gif": "image/gif", + ".webp": "image/webp", + ".mp4": "video/mp4", ".mov": "video/quicktime", + ".webm": "video/webm", + } + return fallbacks.get(ext, "application/octet-stream") + + +def upload_image(client: Client, image_path: str, alt_text: str = "") -> models.AppBskyEmbedImages.Image | None: + try: + mime = detect_mime_type(image_path) + with open(image_path, "rb") as f: + data = f.read() + + logging.info(f"🖼️ Uploading image: {image_path} ({len(data)/1024:.1f} KB, {mime})") + response = client.upload_blob(data) + logging.info("✅ Image uploaded successfully.") + + return models.AppBskyEmbedImages.Image( + image=response.blob, + alt=alt_text, + ) + + except Exception as e: + logging.error(f"❌ Failed to upload image: {repr(e)}") + return None + + +def upload_video(client: Client, video_path: str, alt_text: str = "") -> models.AppBskyEmbedVideo.Main | None: + try: + mime = detect_mime_type(video_path) + with open(video_path, "rb") as f: + data = f.read() + + logging.info(f"🎬 Uploading video: {video_path} ({len(data)/1024:.1f} KB, {mime})") + response = client.upload_blob(data) + logging.info("✅ Video blob uploaded successfully.") + + return models.AppBskyEmbedVideo.Main( + video=response.blob, + alt=alt_text, + ) + + except Exception as e: + logging.error(f"❌ Failed to upload video: {repr(e)}") + return None + + +# ============================================================ +# Post +# ============================================================ +def post_to_bsky( + client: Client, + text: str, + langs: list[str], + image_path: str | None = None, + video_path: str | None = None, + alt_text: str = "", +) -> bool: + rich_text = make_rich(text) + embed = None + + if image_path: + image = upload_image(client, image_path, alt_text=alt_text) + if not image: + logging.error("❌ Aborting post: image upload failed.") + return False + embed = models.AppBskyEmbedImages.Main(images=[image]) + + elif video_path: + video = upload_video(client, video_path, alt_text=alt_text) + if not video: + logging.error("❌ Aborting post: video upload failed.") + return False + embed = video + + try: + logging.info(f"🚀 Sending post (langs={langs}, text={text!r})") + result = client.send_post(text=rich_text, embed=embed, langs=langs) + uri = getattr(result, "uri", None) + logging.info(f"✅ Post published! URI: {uri}") + return True + + except Exception as e: + 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 a Bluesky instance." + ) + 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://eurosky.social", help="Bluesky 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("--alt", default="", help="Alt text for the attached media") + args = parser.parse_args() + + if args.image and args.video: + logging.error("❌ Cannot attach both --image and --video at the same time.") + sys.exit(1) + + if args.image and not os.path.isfile(args.image): + logging.error(f"❌ Image file not found: {args.image}") + sys.exit(1) + + if args.video and not os.path.isfile(args.video): + logging.error(f"❌ Video file not found: {args.video}") + sys.exit(1) + + langs = [l.strip() for l in args.lang.split(",") if l.strip()] or ["ca"] + logging.info(f"🌍 Language(s): {langs}") + + client = Client(base_url=args.service) + logged_in = login_with_backoff( + client=client, + username=args.username, + password=args.password, + service_url=args.service, + ) + if not logged_in: + logging.error("❌ Could not log in. Exiting.") + sys.exit(1) + + success = post_to_bsky( + client=client, + text=args.text, + langs=langs, + image_path=args.image, + video_path=args.video, + alt_text=args.alt, + ) + + sys.exit(0 if success else 1) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/jenkins/dijous b/jenkins/dijous new file mode 100644 index 0000000..bf1e8f1 --- /dev/null +++ b/jenkins/dijous @@ -0,0 +1,42 @@ +pipeline { + agent any + + triggers { + // Every Thursday at 07:15 + cron('15 7 * * 4') + } + + environment { + BSKY_HANDLE = credentials('BSKY_GROMENAWARE_HANDLE') + BSKY_APP_PASSWORD = credentials('BSKY_GROMENAWARE_APP_PASSWORD') + } + + options { + timeout(time: 10, unit: 'MINUTES') + buildDiscarder(logRotator(numToKeepStr: '30')) + } + + stages { + stage('Post Dijous') { + steps { + sh """ + python3 bsky_post.py "Dijous!!!!" \\ + --username "\$BSKY_HANDLE" \\ + --password "\$BSKY_APP_PASSWORD" \\ + --image thursday.jpg \\ + --alt "Dijous!" \\ + --lang ca + """ + } + } + } + + post { + success { + echo '✅ Dijous post published successfully.' + } + failure { + echo '❌ Dijous post failed.' + } + } +} \ No newline at end of file diff --git a/media/dijous.jpg b/media/dijous.jpg new file mode 100644 index 0000000..a6c5e16 Binary files /dev/null and b/media/dijous.jpg differ diff --git a/media/diumenge.mp4 b/media/diumenge.mp4 new file mode 100644 index 0000000..7fa5986 Binary files /dev/null and b/media/diumenge.mp4 differ diff --git a/media/divendres.mp4 b/media/divendres.mp4 new file mode 100644 index 0000000..56f35ae Binary files /dev/null and b/media/divendres.mp4 differ diff --git a/media/divendres_nit.mp4 b/media/divendres_nit.mp4 new file mode 100644 index 0000000..fa9ac3f Binary files /dev/null and b/media/divendres_nit.mp4 differ