"""Shared stdlib-only utilities for FeedEcho. Created by the 2026-09-08 code-duplication audit (Tier 1 adoption). House rules: - Standard library only. The destination modules (discord.py, matrix.py, webhook.py, ...) and app.py import this module; anything that touches database.py or settings.py here would create a cycle. - ``response`` parameters are httpx.Response objects. - Every function here replaces at least two formerly copy-pasted implementations. New shared code lands here only when it has two users. NOT wired up yet (later audit tranches): the four email regex call sites migrate to EMAIL_RE / is_valid_email() after the strict-vs-loose product decision, and plans.py's allowance checks stay local until the Tier 3 refactor. """ from __future__ import annotations import json import re from datetime import datetime, timezone from email.utils import parsedate_to_datetime from typing import TypedDict # Default HTTP request timeout (seconds) shared by the destination # modules. Replaces the four independent REQUEST_TIMEOUT = 30 constants. DEFAULT_REQUEST_TIMEOUT = 30 def utc_now_str() -> str: """Explicit UTC timestamp string, safe to bind straight into SQL. Never CURRENT_TIMESTAMP on Postgres: it resolves in the session time zone while every reader assumes UTC, so modules stamp rows by binding this string as a parameter instead. """ return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") # Email shape checks. Both variants reject spaces/control characters (a # requirement for addresses that can become message headers); they differ # on whether the domain must contain a dot. The loose variant exists # because internal addresses like feedecho@localhost are legitimate for # SMTP relay config. Call sites choose; see is_valid_email. EMAIL_RE = re.compile(r"^[^@\s\r\n]+@[^@\s\r\n]+\.[^@\s\r\n]+$") EMAIL_RE_LOOSE = re.compile(r"^[^@\s\r\n]+@[^@\s\r\n]+$") def is_valid_email(email: str, *, require_domain_dot: bool = True) -> bool: """Syntactic email validation. require_domain_dot=True (default) requires user@host.tld; internal/bare-host addresses pass only with False. """ pattern = EMAIL_RE if require_domain_dot else EMAIL_RE_LOOSE return bool(pattern.match(email)) _URL_RE = re.compile(r"https?://[^\s<>\"']+") def truncate_chars(text: str, max_chars: int, *, preserve_links: bool = True) -> str: """Truncate to max_chars without splitting trailing whitespace. Identical to the former discord.truncate_content and matrix._truncate_body bodies. When preserve_links=True (default), preserves trailing URLs by truncating preceding text instead of severing the link. """ if len(text) <= max_chars: return text 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() standard_cut = max_chars - 1 naive_head = text[:standard_cut].rstrip() needs_preservation = False if u_start > 0: if u_end >= standard_cut: needs_preservation = True elif naive_head.endswith(last_url.group(0)): needs_preservation = True if needs_preservation: tail = text[u_start:].rstrip() if len(tail) > max_chars: tail = text[u_start:u_end].rstrip() tail_len = len(tail) if tail_len <= max_chars: head_budget = max_chars - tail_len - 2 if head_budget > 0: head_str = text[:u_start][:head_budget].rstrip() + "\u2026" result = head_str.rstrip() + " " + tail.lstrip() elif tail_len <= max_chars - 2: result = "\u2026 " + tail.lstrip() elif tail_len <= max_chars - 1: result = "\u2026" + tail.lstrip() else: result = tail.strip() if len(result) <= max_chars: return result.rstrip() return text[: max_chars - 1].rstrip() + "\u2026" def categories_from_value(value) -> dict: """Parse the ``feed_items.categories`` JSON column into a dict. Tolerates NULL (column added in v1.73.2), malformed JSON, and non-dict payloads; every failure mode reads as "no categories". """ if not value: return {} try: parsed = json.loads(value) except (ValueError, TypeError): return {} return parsed if isinstance(parsed, dict) else {} def json_error_detail(response, *keys: str) -> str: """Extract a human-readable error message from a JSON error body. Keys are tried left to right with ``a or b`` fallthrough semantics (matching the former destination-module copies): the first truthy VALUE wins, and it must itself be a non-blank string; it is then stripped and capped at 200 characters. A truthy non-string value (dict, list, number) yields "" just like the originals did. Returns "" when the body is not JSON or not a dict. """ try: body = response.json() except ValueError: return "" if not isinstance(body, dict): return "" msg = next((body.get(key) for key in keys if body.get(key)), None) if isinstance(msg, str) and msg.strip(): return msg.strip()[:200] return "" def parse_retry_after(response) -> float | None: """Seconds the remote asked the client to wait, or None. The Retry-After header wins when present: parsed as plain seconds or as an RFC 7231 HTTP-date (clamped at 0 for past dates). An absent or unparseable header falls back to retry_after/retryAfter in a JSON body. Body values are returned as-is, including 0 (some services answer rate-limit responses with retry_after: 0), matching the former discord.py behavior. """ header = response.headers.get("Retry-After") if response.headers else None if header: try: return float(header) except ValueError: pass try: retry_dt = parsedate_to_datetime(header) except (TypeError, ValueError): retry_dt = None if retry_dt is not None: # RFC 7231 also permits the asctime() format, which carries no # timezone; treat it as UTC so subtracting the aware now() # below cannot raise TypeError. if retry_dt.tzinfo is None: retry_dt = retry_dt.replace(tzinfo=timezone.utc) wait = (retry_dt - datetime.now(timezone.utc)).total_seconds() return max(wait, 0.0) try: body = response.json() if isinstance(body, dict): value = body.get("retry_after") if value is None: value = body.get("retryAfter") if isinstance(value, (int, float)): return float(value) except ValueError: pass return None def rows_to_dict(rows) -> dict: """Collapse a list of settings rows to {key: value} (audit finding 2.4). Replaces the inline dict comprehension in email_sender.get_smtp_settings, email_sender.get_system_smtp_settings, and alt_text._get_settings. """ return {row["key"]: row["value"] for row in rows} # Shared exception ancestors for the five destination adapters (bluesky, # discord, matrix, microblog, webhook — mastodon has no custom exceptions # today). Each module's own Error keeps subclassing Exception # via this shared base instead, and its Auth/NotFound/RateLimit subclasses # additionally inherit the matching Destination*Error below via multiple # inheritance — every existing `except BlueskyAuthError` (etc.) call site # is unaffected, since the module-specific class still exists with the # same name and MRO position. This only adds a shared ancestor so code # that wants to (e.g. a future generic exception ladder in scheduler.py) # can catch "any destination's auth failure" without importing all five # modules' independent hierarchies. Design-patterns audit finding 2.3. class DestinationError(Exception): """Base for every destination-adapter failure.""" class DestinationAuthError(DestinationError): """Rejected credentials or insufficient permission — permanent, not worth retrying.""" class DestinationNotFoundError(DestinationError): """The target (channel/room/blog/webhook/instance) no longer exists.""" class DestinationRateLimitError(DestinationError): """Caller should back off. Subclasses set retry_after (seconds) themselves.""" retry_after: float | None = None class FeedItem(TypedDict, total=False): """The template-facing shape of one feed item, as consumed by render_template() and every _send_X destination function in scheduler.py. Documentation, not an enforced contract: total=False because a backdated/drip-redelivered item reconstructed from the feed_items table may carry a subset of these keys, and because feed_parser.py's parse_rss_feed/parse_json_feed (the source of a freshly-fetched item) is the only place all of them are populated at once. Design-patterns audit finding 4.3 — added at the scheduler.py dispatch boundary as a zero-runtime-cost type hint; no call site's actual dict construction changes. """ id: str title: str link: str summary: str content: str content_text: str content_link: str content_html: str author: str date: str tags: list[str] categories: dict[str, str] image_url: str image_alt: str image_urls: list[dict] enclosure_url: str raw: dict