"""Bluesky (AT Protocol) client — sessions, PDS resolution, posts, and images. FeedEcho connects to Bluesky using app passwords (the standard method for bots and automation). App passwords are created in the Bluesky app under Settings > Privacy & Security > App Passwords. They are scoped to app-level actions (posting among them) and cannot change account settings or be used to log in to the app. """ from __future__ import annotations import base64 import json import re import unicodedata from datetime import datetime, timedelta, timezone from urllib.parse import quote import httpx from feed_parser import SSRFError, pinned_request, validate_outbound_url from utils import ( DestinationAuthError, DestinationError, DestinationNotFoundError, json_error_detail, ) PUBLIC_API = "https://public.api.bsky.app" PLC_DIRECTORY = "https://plc.directory" POST_COLLECTION = "app.bsky.feed.post" POST_RECORD_TYPE = "app.bsky.feed.post" MAX_POST_GRAPHEMES = 300 # app.bsky.feed.post's `text` field caps at maxLength: 3000 UTF-8 bytes, a # second, independent limit from maxGraphemes above — a post can be under # 300 grapheme clusters and still be over 3000 bytes (e.g. ZWJ emoji # sequences, which pack many bytes into one cluster). MAX_POST_BYTES = 3000 MAX_ALT_GRAPHEMES = 1000 # Bluesky raised the app.bsky.embed.images limit 1 MB -> 2 MB in April 2026 # (atproto PR #4823). Images above this are downscaled/re-encoded by # images.downscale_image before upload (scheduler._send_bluesky). MAX_BLOB_BYTES = 2_000_000 BLUESKY_IMAGE_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"} # app.bsky.embed.images carries up to 4 images per post; scheduler._send_bluesky # attaches the item's image_urls up to this cap (Mastodon's default cap is the # same number, but the two are independent transport limits). MAX_IMAGES = 4 # How long to trust a cached access JWT before refreshing (JWT exp takes # precedence when decodable). Access tokens typically live ~2 hours. DEFAULT_SESSION_TTL_SECONDS = 2 * 60 * 60 class BlueskyError(DestinationError): """Base error for Bluesky API interactions.""" class BlueskyAuthError(BlueskyError, DestinationAuthError): """Credentials rejected, session expired, or app password revoked.""" class BlueskyAccountGoneError(BlueskyError, DestinationNotFoundError): """The account row was deleted mid-dispatch — retrying can never help.""" def _error_detail(response) -> str: """Extract the PDS-provided error message from a JSON error body.""" return json_error_detail(response, "message", "error") def _is_token_error(detail: str) -> bool: """Detect expired/revoked-token responses regardless of status code. bsky.social historically returns ExpiredToken as a 400; treat both explicit 401s and ExpiredToken/InvalidToken bodies as auth failures. """ return "ExpiredToken" in detail or "InvalidToken" in detail # ── Handle normalization ───────────────────────────────────────────────────── _HANDLE_RE = re.compile( r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$" ) _HANDLE_MAX_LEN = 253 def normalize_handle(raw: str) -> str: """Normalize user-entered handles to lowercase bare form. Accepts "Name", "@Name.Bsky.Social", and "https://bsky.app/profile/x" style inputs (the host part is taken from URLs). Enforces the AT Protocol handle grammar and length limit. Raises ValueError on invalid input. """ value = (raw or "").strip().lower().removeprefix("@") value = re.sub(r"^https?://", "", value) # Take the last path segment if given a profile URL. value = value.split("/")[-1].strip() value = value.rstrip(".").strip() if not value or len(value) > _HANDLE_MAX_LEN or not _HANDLE_RE.match(value): raise ValueError("Enter a valid Bluesky handle, e.g. username.bsky.social") return value # ── PDS discovery ──────────────────────────────────────────────────────────── def resolve_pds(handle: str) -> tuple[str, str]: """Resolve a handle to (did, pds_url) via the public identity service. Handles hosted on bsky.social and custom PDSes both work: the DID document's #atproto_pds service entry tells us where the account's data lives, so we never hardcode bsky.social. """ handle = normalize_handle(handle) validate_outbound_url(PUBLIC_API) try: response = pinned_request( "GET", f"{PUBLIC_API}/xrpc/com.atproto.identity.resolveHandle", timeout=15.0, params={"handle": handle}, ) response.raise_for_status() data = response.json() except (httpx.HTTPError, ValueError) as exc: raise BlueskyError(f"Could not resolve handle '{handle}'") from exc did = data.get("did") if not isinstance(did, str) or not did: raise BlueskyError(f"Handle '{handle}' did not resolve to a DID") if did.startswith("did:web:"): # did:web:example.com -> https://example.com/.well-known/did.json doc_url = f"https://{did.removeprefix('did:web:')}/.well-known/did.json" # The doc host is derived from remote identity data — SSRF-validate it # before fetching, like every other outbound hop. validate_outbound_url(doc_url) try: response = pinned_request("GET", doc_url, timeout=15.0) response.raise_for_status() doc = response.json() except (httpx.HTTPError, ValueError) as exc: raise BlueskyError(f"Could not fetch DID document for '{handle}'") from exc else: # did:plc:... -> query the PLC directory for the PDS service endpoint. try: response = pinned_request( "GET", f"{PLC_DIRECTORY}/{quote(did, safe='')}", timeout=15.0 ) response.raise_for_status() doc = response.json() except (httpx.HTTPError, ValueError) as exc: raise BlueskyError(f"Could not fetch DID document for '{handle}'") from exc # The DID document comes from a remote (for did:web, handle-controlled) # host, so nothing about its shape is guaranteed. Without these checks a # document that is a JSON array or has non-dict service entries raised # AttributeError, which none of the callers' except clauses catch. if not isinstance(doc, dict): raise BlueskyError(f"Invalid DID document for '{handle}'") pds = "" for service in doc.get("service") or []: if not isinstance(service, dict): continue endpoint = service.get("serviceEndpoint") if service.get("id") == "#atproto_pds" and isinstance(endpoint, str) and endpoint: pds = endpoint break if not pds: raise BlueskyError(f"No PDS endpoint found for '{handle}'") pds = pds.rstrip("/") # The PDS hostname came from a remote identity document — validate it # against SSRF/private-IP rules before making requests to it. validate_outbound_url(pds) return did, pds # ── Sessions ───────────────────────────────────────────────────────────────── def _decode_jwt_exp(jwt_str: str) -> datetime | None: """Return the exp claim as an aware UTC datetime, or None if undecodable.""" try: payload = jwt_str.split(".")[1] # base64url decode with padding restored padded = payload + "=" * (-len(payload) % 4) claims = json.loads(base64.urlsafe_b64decode(padded)) exp = claims.get("exp") if isinstance(exp, (int, float)): return datetime.fromtimestamp(exp, tz=timezone.utc) except (IndexError, ValueError, TypeError, json.JSONDecodeError): pass return None def create_session(pds: str, handle: str, app_password: str) -> dict: """Create a session with an app password. Returns did + JWTs.""" pds = pds.rstrip("/") validate_outbound_url(pds) try: response = pinned_request( "POST", f"{pds}/xrpc/com.atproto.server.createSession", timeout=30.0, json={"identifier": handle, "password": app_password}, ) except (httpx.RequestError, SSRFError) as exc: raise BlueskyError(f"Could not reach PDS {pds}") from exc if response.status_code in (400, 401): detail = _error_detail(response) suffix = f" ({detail})" if detail else "" raise BlueskyAuthError(f"Invalid handle or app password{suffix}") if response.status_code >= 400: detail = _error_detail(response) suffix = f" ({detail})" if detail else "" raise BlueskyError(f"PDS returned HTTP {response.status_code}{suffix}") try: data = response.json() except ValueError as exc: raise BlueskyError("PDS returned an invalid session response") from exc did = data.get("did") access_jwt = data.get("accessJwt") refresh_jwt = data.get("refreshJwt") if not all(isinstance(v, str) and v for v in (did, access_jwt, refresh_jwt)): raise BlueskyError("PDS returned an incomplete session response") return {"did": did, "access_jwt": access_jwt, "refresh_jwt": refresh_jwt} def refresh_session(pds: str, refresh_jwt: str) -> dict: """Refresh a session using its refresh JWT.""" pds = pds.rstrip("/") validate_outbound_url(pds) try: response = pinned_request( "POST", f"{pds}/xrpc/com.atproto.server.refreshSession", timeout=30.0, headers={"Authorization": f"Bearer {refresh_jwt}"}, ) except (httpx.RequestError, SSRFError) as exc: raise BlueskyError(f"Could not reach PDS {pds}") from exc if response.status_code in (400, 401): detail = _error_detail(response) suffix = f" ({detail})" if detail else "" raise BlueskyAuthError(f"Session refresh rejected{suffix}") if response.status_code >= 400: detail = _error_detail(response) suffix = f" ({detail})" if detail else "" raise BlueskyError(f"PDS returned HTTP {response.status_code}{suffix}") try: data = response.json() except ValueError as exc: raise BlueskyError("PDS returned an invalid refresh response") from exc access_jwt = data.get("accessJwt") new_refresh = data.get("refreshJwt") did = data.get("did") if not isinstance(access_jwt, str) or not access_jwt or not isinstance(did, str): raise BlueskyError("PDS returned an incomplete refresh response") if not isinstance(new_refresh, str) or not new_refresh: # AT Protocol rotates refresh tokens on every refresh; a missing # rotated token means the old one is dead. Do not reuse it. raise BlueskyError("PDS refresh response omitted the rotated refresh token") return {"did": did, "access_jwt": access_jwt, "refresh_jwt": new_refresh} def delete_session(pds: str, refresh_jwt: str) -> None: """Best-effort session cleanup (used by connection tests). Never raises.""" pds = pds.rstrip("/") try: validate_outbound_url(pds) pinned_request( "POST", f"{pds}/xrpc/com.atproto.server.deleteSession", timeout=15.0, headers={"Authorization": f"Bearer {refresh_jwt}"}, ) except Exception: # SSRF validation failure or network error — cleanup is best-effort. pass def session_expiry(access_jwt: str) -> str: """SQLite timestamp for when a cached access JWT should be refreshed.""" exp = _decode_jwt_exp(access_jwt) if exp is None: exp = datetime.now(timezone.utc) + timedelta(seconds=DEFAULT_SESSION_TTL_SECONDS) else: # Refresh a minute early to avoid racing the actual expiry. exp = exp - timedelta(seconds=60) return exp.strftime("%Y-%m-%d %H:%M:%S") # ── Rich text facets ───────────────────────────────────────────────────────── _URL_RE = re.compile(r"https?://[^\s<>\"']+") # Hashtag detection mirrors Bluesky's own richtext rules (atproto: # packages/api/src/rich-text/util.ts TAG_REGEX): a tag starts at the # beginning of the text or after whitespace, uses an ASCII or fullwidth # hash, and runs to the next whitespace or zero-width character. The # candidate's trailing punctuation is stripped, it must keep at least one # character that is neither an ASCII digit nor punctuation (so "#123" is # not a tag while a tag of non-ASCII digits is -- JS \d matches ASCII # only), and it caps at 64 graphemes / 640 bytes -- the client and the # tag lexicon limits. Cashtags ($TICKER) and mentions (@handle) are deliberately not # detected: feeds carry foreign @user@instance handles that cannot resolve # to Bluesky DIDs, and $TICKER content is rare in feeds. _TAG_RE = re.compile( r"(^|\s)([##](?!\ufe0f)[^\s\u00AD\u2060\u200A\u200B\u200C\u200D\u20e2]*)" ) _TAG_MAX_CHARS = 64 _TAG_MAX_BYTES = 640 _ASCII_DIGITS = "0123456789" _FACET_LINK_TYPE = "app.bsky.richtext.facet#link" _FACET_TAG_TYPE = "app.bsky.richtext.facet#tag" def _strip_trailing_punctuation(value: str) -> str: """Drop trailing Unicode punctuation (atproto trim + TRAILING_PUNCTUATION).""" while value and unicodedata.category(value[-1]).startswith("P"): value = value[:-1] return value def _has_tag_body_char(tag: str) -> bool: r"""Whether the tag keeps a character that is neither digit nor punctuation. atproto's TAG_REGEX requires one such character inside the body; it is what makes "#123" render as plain text rather than a tag. Only ASCII digits are excluded (JS \d is ASCII-only), so a tag of non-ASCII digits like "#٢٠٢٦" is a tag to the official client and stays one here. `tag` is always derived from a _TAG_RE match with trailing punctuation stripped, so it can never contain whitespace or the zero-width characters _TAG_RE's body class already excludes -- only digits and punctuation need checking here. """ for ch in tag: if ch in _ASCII_DIGITS: continue if unicodedata.category(ch).startswith("P"): continue return True return False def build_facets( text: str, extra_links: list[tuple[int, int, str]] | None = None ) -> list[dict]: """Find URLs and hashtags in post text and build richtext facets. Byte offsets are relative to the UTF-8 encoding of the text, as required by app.bsky.richtext.facet. Link and tag detection follow Bluesky's own richtext rules: trailing punctuation is trimmed from each URL; a tag must start the text or follow whitespace, and keeps at least one non-digit, non-punctuation character. Truncation artifacts are dropped ("…" inside a match), and a tag candidate overlapping a URL range is skipped so facets never overlap. extra_links: optional (char_start, char_end, uri) spans recovered from rich rendering ({{ content_html }} anchors, see template_engine .render_template_rich). They take precedence: URL and tag detection skip any candidate overlapping an extra span, and spans beyond the text (truncated away) are dropped. """ encoded = text.encode("utf-8") # (char_start, char_end, facet) — overlap checks run in character space; # byte offsets are derived from character positions per final match. found: list[tuple[int, int, dict]] = [] # Seed with rich-render link spans. Offsets computed from the text # itself are always valid decodes, so no truncation guard is needed; # spans past the end (content truncated away) are dropped here. extra_spans: list[tuple[int, int]] = [] for char_start, char_end, uri in extra_links or []: if not (0 <= char_start < char_end <= len(text)) or not uri: continue # Overlapping extra spans would produce overlapping facets, which # Bluesky's record validation rejects. The converter cannot emit # them (anchors never nest), so treat overlap as a caller bug and # keep the first span. if any(char_start < end and start < char_end for start, end in extra_spans): continue byte_start = len(text[:char_start].encode("utf-8")) byte_end = len(text[:char_end].encode("utf-8")) found.append( ( char_start, char_end, { "index": {"byteStart": byte_start, "byteEnd": byte_end}, "features": [ {"$type": _FACET_LINK_TYPE, "uri": uri}, ], }, ) ) extra_spans.append((char_start, char_end)) for match in _URL_RE.finditer(text): uri = match.group(0) # If the truncation ellipsis is inside the match, the URL was cut off # mid-way — drop it instead of linkifying a broken/partial URL. if "…" in uri: continue # Strip trailing punctuation that is unlikely to be part of the URL. while uri and uri[-1] in ".,;:!?'\"()[]{}<>": uri = uri[:-1] if not uri: continue # Locate the trimmed URI's byte range inside the encoded text. char_start = match.start() byte_start = len(text[:char_start].encode("utf-8")) byte_end = byte_start + len(uri.encode("utf-8")) # If the text was truncated mid-URL, the byte range won't decode back # to the full URI — drop the facet instead of linkifying a broken URL. if byte_end > len(encoded) or encoded[byte_start:byte_end].decode("utf-8") != uri: continue # A rich-render anchor already covers this range — keep its facet. span_end = char_start + len(uri) if any(char_start < end and start < span_end for start, end in extra_spans): continue found.append( ( char_start, char_start + len(uri), { "index": {"byteStart": byte_start, "byteEnd": byte_end}, "features": [ {"$type": _FACET_LINK_TYPE, "uri": uri}, ], }, ) ) link_spans = [(start, end) for start, end, _ in found] for match in _TAG_RE.finditer(text): leading = match.group(1) candidate = match.group(2) # Same truncation guard as URLs: an ellipsis inside the candidate # means the tag was sliced mid-word. if "…" in candidate: continue tag = _strip_trailing_punctuation(candidate[1:]) if not tag: continue # Tag cap: 64 graphemes (the lexicon limit). len(tag) is the cheap # pre-check the client also uses -- code points are always >= # graphemes, so cluster counting only runs for over-cap raw lengths. # The lexicon separately caps the property at 640 UTF-8 bytes; an # oversized tag facet would fail record validation and kill the # whole post, so guard that too. if ( len(tag) > _TAG_MAX_CHARS and len(_grapheme_clusters(tag)) > _TAG_MAX_CHARS ): continue if len(tag.encode("utf-8")) > _TAG_MAX_BYTES: continue if not _has_tag_body_char(tag): continue char_start = match.start() + len(leading) # position of the hash char_end = char_start + 1 + len(tag) if any(char_start < end and start < char_end for start, end in link_spans): continue byte_start = len(text[:char_start].encode("utf-8")) byte_end = len(text[:char_end].encode("utf-8")) found.append( ( char_start, char_end, { "index": {"byteStart": byte_start, "byteEnd": byte_end}, "features": [ {"$type": _FACET_TAG_TYPE, "tag": tag}, ], }, ) ) found.sort(key=lambda entry: entry[0]) return [facet for _, _, facet in found] # ── Grapheme-aware truncation ──────────────────────────────────────────────── _ZWJ = "\u200d" _VARIATION_SELECTORS = {"\ufe0e", "\ufe0f"} _SKIN_TONE_RANGE = range(0x1F3FB, 0x1F400) _EMOJI_TAG_RANGE = range(0xE0020, 0xE0080) # Python's unicodedata does not expose Extended_Pictographic (it is not a # general category), and the `regex` package that provides # \p{Extended_Pictographic} is not a project dependency (checked # requirements.txt / pyproject.toml). This is a range-based approximation # covering the Unicode blocks that hold the overwhelming majority of # real-world emoji, used only to gate GB11 below. It is NOT a complete # UAX #29 implementation: a few rare Extended_Pictographic code points # outside these ranges will be missed (under-glue -- same failure mode the # old code already had for other boundaries, harmless for the 300/3000 # caps, just an occasional visually-split emoji), but ordinary letters, # digits, and punctuation are reliably excluded, which is what fixes the # bug this guards against. _EXTENDED_PICTOGRAPHIC_RANGES = ( (0x00A9, 0x00A9), # copyright (0x00AE, 0x00AE), # registered (0x203C, 0x203C), # double exclamation (0x2049, 0x2049), # exclamation question (0x2122, 0x2122), # trademark (0x2139, 0x2139), # information (0x2194, 0x21AA), # arrows used as emoji (0x231A, 0x231B), # watch, hourglass (0x2328, 0x2328), # keyboard (0x23CF, 0x23CF), (0x23E9, 0x23FA), # playback controls / clocks (0x24C2, 0x24C2), (0x25AA, 0x25FE), # geometric shapes used as emoji (0x2600, 0x27BF), # misc symbols + dingbats (most classic emoji) (0x2934, 0x2935), (0x2B00, 0x2BFF), # misc symbols and arrows (stars, etc.) (0x3030, 0x3030), (0x303D, 0x303D), (0x3297, 0x3297), (0x3299, 0x3299), (0x1F000, 0x1FAFF), # mahjong/dominoes through symbols & pictographs ext-A (0x1FB00, 0x1FBFF), # legacy computing symbols (some emoji-adjacent) ) def _is_regional_indicator(ch: str) -> bool: return 0x1F1E6 <= ord(ch) <= 0x1F1FF def _is_extended_pictographic(ch: str) -> bool: """Approximate check for Unicode's Extended_Pictographic property. Restricts the ZWJ forward-glue (GB11) below to actual emoji continuations -- see _EXTENDED_PICTOGRAPHIC_RANGES for why this is a range-based stand-in rather than a full property lookup. """ cp = ord(ch) return any(lo <= cp <= hi for lo, hi in _EXTENDED_PICTOGRAPHIC_RANGES) def _trailing_ri_count(cluster: str) -> int: n = 0 for ch in reversed(cluster): if _is_regional_indicator(ch): n += 1 else: break return n def _grapheme_clusters(text: str) -> list[str]: """Split text into grapheme clusters using a conservative UAX #29 subset. A new grapheme starts at any character that is not a combining mark, zero-width joiner, variation selector, skin-tone modifier, or emoji tag character. A ZWJ always glues to the cluster before it (GB9). A character *after* a ZWJ only glues onto that cluster when it is itself emoji/Extended_Pictographic (an approximation of GB11) -- a ZWJ used for non-emoji purposes (e.g. some Indic-script ligatures) no longer swallows whatever ordinary character happens to follow it. Regional-indicator pairs (flags) merge so a flag never splits. The remaining gaps over-merge, which can only under-count graphemes, so truncation never exceeds platform limits (that reasoning does NOT extend to the ZWJ forward-glue case, which is why it is now gated on Extended_Pictographic instead of firing unconditionally). """ clusters: list[str] = [] for ch in text: cat = unicodedata.category(ch) is_continuation = ( cat in ("Mn", "Mc", "Me") or ch in _VARIATION_SELECTORS or ord(ch) in _SKIN_TONE_RANGE or ord(ch) in _EMOJI_TAG_RANGE ) if clusters: is_continuation = ( is_continuation or ch == _ZWJ or ( clusters[-1][-1] == _ZWJ and _is_extended_pictographic(ch) ) or ( _is_regional_indicator(ch) and _trailing_ri_count(clusters[-1]) == 1 ) ) if clusters and is_continuation: clusters[-1] += ch else: clusters.append(ch) return clusters def truncate_graphemes( text: str, max_graphemes: int = MAX_POST_GRAPHEMES, max_bytes: int | None = None, *, preserve_links: bool = True, ) -> str: """Truncate to max_graphemes grapheme clusters, appending an ellipsis. When max_bytes is given, the result is also capped at that many UTF-8 bytes -- app.bsky.feed.post's `text` enforces maxGraphemes AND maxLength (bytes) as two independent limits, and a handful of grapheme clusters can still add up to well over 3000 bytes (e.g. dense ZWJ emoji sequences). Byte truncation drops whole grapheme clusters from the end rather than slicing raw bytes, so it never splits a UTF-8 multi-byte sequence. When preserve_links=True (default), if a trailing URL would be severed or dropped by right-side truncation, preceding text is truncated instead so the link remains intact and clickable. """ clusters = _grapheme_clusters(text) fits_graphemes = len(clusters) <= max_graphemes fits_bytes = max_bytes is None or len(text.encode("utf-8")) <= max_bytes if fits_graphemes and fits_bytes: return text def _assemble_slice(cl: list[str], n: int) -> str: head = "".join(cl[:n]).rstrip() # Don't leave a dangling ZWJ at the cut point -- it renders as a # broken sequence in most clients. while head.endswith(_ZWJ): head = head[:-1] return head + "…" if preserve_links: urls = list(_URL_RE.finditer(text)) if urls: last_url = urls[-1] u_start = last_url.start() u_end = last_url.end() char_idx = 0 u_start_cluster = None u_end_cluster = None for i, c in enumerate(clusters): if u_start_cluster is None and char_idx <= u_start < char_idx + len(c): u_start_cluster = i char_idx += len(c) if char_idx >= u_end and u_end_cluster is None: u_end_cluster = i + 1 # Determine where naive right-side truncation would cut naive_n = min(max_graphemes - 1, len(clusters)) if not fits_graphemes else len(clusters) if max_bytes is not None: while naive_n > 0 and len(_assemble_slice(clusters, naive_n).encode("utf-8")) > max_bytes: naive_n -= 1 naive_head = "".join(clusters[:naive_n]).rstrip() needs_preservation = False if u_start_cluster is not None and u_start_cluster > 0: if u_end_cluster is not None and u_end_cluster >= naive_n: needs_preservation = True elif naive_head.endswith(last_url.group(0)): needs_preservation = True if needs_preservation and u_start_cluster is not None: tail_clusters = clusters[u_start_cluster:] tail_str = "".join(tail_clusters).rstrip() # If tail with suffix exceeds limit, try just the URL itself if len(_grapheme_clusters(tail_str)) > max_graphemes and u_end_cluster is not None: tail_clusters = clusters[u_start_cluster:u_end_cluster] tail_str = "".join(tail_clusters).rstrip() tail_graphemes = len(_grapheme_clusters(tail_str)) if tail_graphemes <= max_graphemes: head_clusters = clusters[:u_start_cluster] head_budget = max_graphemes - tail_graphemes - 2 if head_budget > 0: head_str = _assemble_slice(head_clusters, head_budget) result = head_str.rstrip() + " " + tail_str.lstrip() elif tail_graphemes <= max_graphemes - 2: result = "… " + tail_str.lstrip() elif tail_graphemes <= max_graphemes - 1: result = "…" + tail_str.lstrip() else: result = tail_str.strip() if max_bytes is not None: while len(result.encode("utf-8")) > max_bytes and head_budget > 0: head_budget -= 1 if head_budget > 0: head_str = _assemble_slice(head_clusters, head_budget) result = head_str.rstrip() + " " + tail_str.lstrip() elif tail_graphemes <= max_graphemes - 2: result = "… " + tail_str.lstrip() elif tail_graphemes <= max_graphemes - 1: result = "…" + tail_str.lstrip() else: result = tail_str.strip() res_clusters = _grapheme_clusters(result) if len(res_clusters) <= max_graphemes and ( max_bytes is None or len(result.encode("utf-8")) <= max_bytes ): return result.rstrip() n = min(max_graphemes - 1, len(clusters)) if not fits_graphemes else len(clusters) result = _assemble_slice(clusters, n) if max_bytes is not None: while n > 0 and len(result.encode("utf-8")) > max_bytes: n -= 1 result = _assemble_slice(clusters, n) return result # ── Posts and images ───────────────────────────────────────────────────────── def upload_blob( pds: str, access_jwt: str, image_bytes: bytes, content_type: str ) -> dict | None: """Upload an image blob. Enforces Bluesky's image constraints (type allowlist, 1 MB cap). Returns the blob reference dict, or None on transport failure. Raises BlueskyAuthError on expired/revoked sessions and BlueskyError on other API rejections, so callers can distinguish "skip the image" from "refresh the session". """ pds = pds.rstrip("/") validate_outbound_url(pds) if content_type not in BLUESKY_IMAGE_TYPES: raise BlueskyError(f"Unsupported image type for Bluesky: {content_type}") if len(image_bytes) > MAX_BLOB_BYTES: raise BlueskyError("Image exceeds the Bluesky blob limit") try: response = pinned_request( "POST", f"{pds}/xrpc/com.atproto.repo.uploadBlob", timeout=60.0, headers={ "Authorization": f"Bearer {access_jwt}", "Content-Type": content_type, }, content=image_bytes, ) except (httpx.RequestError, SSRFError): return None detail = _error_detail(response) if response.status_code == 401 or _is_token_error(detail): suffix = f" ({detail})" if detail else "" raise BlueskyAuthError(f"Blob upload rejected: session expired{suffix}") if response.status_code >= 400: suffix = f" ({detail})" if detail else "" raise BlueskyError(f"Blob upload failed (HTTP {response.status_code}){suffix}") try: blob = response.json().get("blob") except ValueError: return None if not isinstance(blob, dict) or "ref" not in blob: return None return blob def create_post( pds: str, access_jwt: str, repo: str, text: str, facets: list[dict] | None = None, embed: dict | None = None, ) -> dict: """Create a post record. Returns {uri, cid}. Raises BlueskyAuthError on 401.""" pds = pds.rstrip("/") validate_outbound_url(pds) record: dict = { "$type": POST_RECORD_TYPE, "text": text, "createdAt": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), } if facets: record["facets"] = facets if embed: record["embed"] = embed payload = {"repo": repo, "collection": POST_COLLECTION, "record": record} try: response = pinned_request( "POST", f"{pds}/xrpc/com.atproto.repo.createRecord", timeout=30.0, headers={"Authorization": f"Bearer {access_jwt}"}, json=payload, ) except (httpx.RequestError, SSRFError) as exc: raise BlueskyError(f"Could not reach PDS {pds}") from exc detail = _error_detail(response) if response.status_code == 401 or _is_token_error(detail): # 401 (or a 400 carrying ExpiredToken/InvalidToken) is a session # problem. A plain 400 is a record validation error, not auth. suffix = f" ({detail})" if detail else "" raise BlueskyAuthError(f"Post rejected (HTTP {response.status_code}){suffix}") if response.status_code >= 400: suffix = f" ({detail})" if detail else "" raise BlueskyError(f"Post rejected (HTTP {response.status_code}){suffix}") try: data = response.json() except ValueError as exc: raise BlueskyError("PDS returned an invalid post response") from exc if not isinstance(data.get("uri"), str) or not isinstance(data.get("cid"), str): raise BlueskyError("PDS returned an incomplete post response") return {"uri": data["uri"], "cid": data["cid"]} def build_image_embed(image_entries: list[dict]) -> dict: """Wrap uploaded blobs in an app.bsky.embed.images embed. Takes a list of {"blob", "alt"} entries (scheduler._send_bluesky builds them) and drops anything beyond MAX_IMAGES defensively — the per-post cap belongs to this transport, not the caller. The lexicon requires at least one image, so an empty list raises ValueError instead of returning a payload the PDS would reject. """ if not image_entries: raise ValueError("build_image_embed requires at least one image entry") images = [ { "alt": truncate_graphemes( (entry.get("alt") or "").strip(), MAX_ALT_GRAPHEMES ), "image": entry["blob"], } for entry in image_entries[:MAX_IMAGES] ] return {"$type": "app.bsky.embed.images", "images": images} EXTERNAL_TITLE_MAX_CHARS = 200 EXTERNAL_DESCRIPTION_MAX_CHARS = 500 # The external thumb carries its own lexicon cap (maxSize 1000000), which # stayed at 1 MB when app.bsky.embed.images was raised to 2 MB in April # 2026 — a 1-2 MB thumb here would upload fine but get the whole record # rejected with a 400 on createRecord. EXTERNAL_THUMB_MAX_BYTES = 1_000_000 def build_external_embed( uri: str, title: str, description: str = "", thumb_blob: dict | None = None ) -> dict: """Build an app.bsky.embed.external link card. The card Bluesky renders when a post carries a link: the linked page's Open Graph title/description, optionally an uploaded thumbnail blob. Truncation is deliberately conservative — well inside the lexicon's byte limits for any plausible title, so the PDS never rejects the record over an oversized card. A missing thumb just renders a text-only card, matching how the official client handles pages without og:image. """ external: dict = { "uri": uri, "title": truncate_graphemes((title or "").strip(), EXTERNAL_TITLE_MAX_CHARS), "description": truncate_graphemes( (description or "").strip(), EXTERNAL_DESCRIPTION_MAX_CHARS ), } if thumb_blob: external["thumb"] = thumb_blob return {"$type": "app.bsky.embed.external", "external": external} def first_link_uri(facets: list[dict] | None) -> str | None: """Return the uri of the first link facet, or None. build_facets seeds rich-render link spans before bare-URL detection, so first-wins here gives the same precedence the rendered post shows: a {{ content_html }} anchor beats a trailing bare permalink. """ for facet in facets or []: for feature in facet.get("features", []): if ( isinstance(feature, dict) and feature.get("$type") == _FACET_LINK_TYPE and feature.get("uri") ): return feature["uri"] return None # ── Connection testing ─────────────────────────────────────────────────────── def test_connection(handle: str, app_password: str) -> tuple[bool, str]: """Resolve the handle and verify the app password works.""" try: handle = normalize_handle(handle) did, pds = resolve_pds(handle) session = create_session(pds, handle, app_password) try: return True, f"Connected as @{handle} ({session['did'][:20]}…)" finally: # Don't accumulate server-side sessions from repeated tests — # bsky.social rate-limits createSession aggressively. delete_session(pds, session["refresh_jwt"]) except ValueError as e: return False, str(e) except BlueskyAuthError: return False, "Invalid handle or app password" except BlueskyError as e: return False, str(e)