feat: add bsky_post.py and Jenkinsfile for weekly Dijous post
- New standalone script `bsky_post.py` to post text + optional image or video to a Bluesky PDS instance. Supports --image, --video (mutually exclusive), --alt, --lang, --service, --username, --password flags. Reuses the same login-with-backoff pattern as rss2bsky and the Twitter bot. - New Jenkinsfile that triggers every Thursday at 07:15 (cron: 15 7 * * 4), reads credentials from Jenkins secret text entries, and runs bsky_post.py with the configured image and Catalan language tag.
This commit is contained in:
314
bsky_post.py
Normal file
314
bsky_post.py
Normal file
@@ -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()
|
||||||
42
jenkins/dijous
Normal file
42
jenkins/dijous
Normal file
@@ -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.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
media/dijous.jpg
Normal file
BIN
media/dijous.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 57 KiB |
BIN
media/diumenge.mp4
Normal file
BIN
media/diumenge.mp4
Normal file
Binary file not shown.
BIN
media/divendres.mp4
Normal file
BIN
media/divendres.mp4
Normal file
Binary file not shown.
BIN
media/divendres_nit.mp4
Normal file
BIN
media/divendres_nit.mp4
Normal file
Binary file not shown.
Reference in New Issue
Block a user