diff --git a/.env.example b/.env.example index 9f0658ce..f039a848 100644 --- a/.env.example +++ b/.env.example @@ -33,6 +33,10 @@ REDDIT_CLIENT_ID= REDDIT_CLIENT_SECRET= REDDIT_USER_AGENT=newsletter-maker/0.1 +# Used to encrypt project-scoped Bluesky app passwords stored in the database. +# Set this to a stable secret in each environment. +BLUESKY_CREDENTIALS_ENCRYPTION_KEY= + # Outbound mail provider. Use Resend or Amazon SES. EMAIL_BACKEND=anymail.backends.resend.EmailBackend DEFAULT_FROM_EMAIL=onboarding@resend.dev diff --git a/core/admin.py b/core/admin.py index db32403c..7285ab7f 100644 --- a/core/admin.py +++ b/core/admin.py @@ -7,6 +7,7 @@ import json +from django import forms from django.contrib import admin, messages from django.db.models import Avg from django.utils import timezone @@ -16,6 +17,7 @@ from unfold.admin import ModelAdmin from core.models import ( + BlueskyCredentials, Content, Entity, IngestionRun, @@ -29,6 +31,42 @@ from core.plugins import get_plugin_for_source_config, validate_plugin_config +class BlueskyCredentialsAdminForm(forms.ModelForm): + """Admin form that accepts a plaintext Bluesky app credential input.""" + + credential_input = forms.CharField( + required=False, + strip=False, + widget=forms.PasswordInput(render_value=False), + help_text="Leave blank to keep the existing stored credential.", + label="Bluesky app credential", + ) + + class Meta: + model = BlueskyCredentials + fields = ["project", "handle", "pds_url", "is_active"] + + def clean(self): + """Require a credential when creating the record for the first time.""" + + cleaned_data = super().clean() + credential_input = cleaned_data.get("credential_input", "") + if not self.instance.has_stored_credential() and not credential_input: + self.add_error("credential_input", "A Bluesky app credential is required.") + return cleaned_data + + def save(self, commit=True): + """Encrypt a new credential value before saving the model instance.""" + + instance = super().save(commit=False) + credential_input = self.cleaned_data.get("credential_input", "") + if credential_input: + instance.set_stored_credential(credential_input) + if commit: + instance.save() + return instance + + @admin.register(Project) class ProjectAdmin(ExportActionMixin, admin.ModelAdmin): """Admin configuration for top-level project workspaces.""" @@ -49,6 +87,100 @@ class ProjectAdmin(ExportActionMixin, admin.ModelAdmin): list_editable = ("content_retention_days",) +@admin.register(BlueskyCredentials) +class BlueskyCredentialsAdmin(ModelAdmin): + """Admin view for project-scoped Bluesky authentication settings.""" + + form = BlueskyCredentialsAdminForm + actions = ["verify_selected_credentials"] + list_display = ( + "project", + "handle", + "display_pds_host", + "has_stored_credential", + "is_active", + "last_verified_at", + ) + list_filter = ("is_active", ("project", admin.RelatedOnlyFieldListFilter)) + search_fields = ("project__name", "handle", "pds_url") + autocomplete_fields = ("project",) + readonly_fields = ( + "has_stored_credential", + "last_verified_at", + "last_error", + "created_at", + "updated_at", + ) + fieldsets = ( + ( + "Account", + {"fields": ("project", "handle", "credential_input", "is_active")}, + ), + ( + "PDS Override", + { + "fields": ("pds_url",), + "description": "Leave blank to use the default Bluesky-hosted account flow.", + }, + ), + ( + "Verification", + { + "fields": ( + "has_stored_credential", + "last_verified_at", + "last_error", + "created_at", + "updated_at", + ) + }, + ), + ) + + @admin.display(description="PDS") + def display_pds_host(self, obj): + """Show whether the credentials use the hosted default or a custom PDS.""" + + return obj.pds_url or "Bluesky hosted default" + + @admin.display(boolean=True, description="Stored Credential") + def has_stored_credential(self, obj): + """Return whether an encrypted Bluesky credential has been configured.""" + + return obj.has_stored_credential() + + @admin.action(description="Verify Selected Credentials") + def verify_selected_credentials(self, request, queryset): + """Authenticate the selected Bluesky accounts and report the outcome.""" + + from core.plugins.bluesky import BlueskySourcePlugin + + verified_credentials = [] + failed_credentials = [] + + for credentials in queryset.select_related("project"): + try: + BlueskySourcePlugin.verify_credentials(credentials) + except Exception as exc: + failed_credentials.append(f"{credentials}: {exc}") + else: + verified_credentials.append(str(credentials)) + + if verified_credentials: + self.message_user( + request, + f"Credential verification passed for {len(verified_credentials)} account(s).", + messages.SUCCESS, + ) + + if failed_credentials: + self.message_user( + request, + "Credential verification failed for: " + "; ".join(failed_credentials), + messages.ERROR, + ) + + @admin.register(ProjectConfig) class ProjectConfigAdmin(admin.ModelAdmin): """Admin configuration for per-project scoring settings.""" diff --git a/core/api.py b/core/api.py index 0d79df79..77690097 100644 --- a/core/api.py +++ b/core/api.py @@ -5,6 +5,7 @@ generated schema consistent across similar viewsets. """ +import logging from typing import Any from drf_spectacular.utils import ( @@ -21,6 +22,7 @@ from rest_framework.response import Response from core.models import ( + BlueskyCredentials, Content, Entity, IngestionRun, @@ -48,6 +50,8 @@ SUMMARIZATION_SKILL_NAME = "summarization" RELATED_CONTENT_SKILL_NAME = "find_related" +logger = logging.getLogger(__name__) + PROJECT_ID_PARAMETER = OpenApiParameter( name="project_id", type=int, @@ -115,6 +119,20 @@ request_only=True, ) +SOURCE_CONFIG_BLUESKY_REQUEST_EXAMPLE = OpenApiExample( + "Create Bluesky Source Request", + value={ + "plugin_name": "bluesky", + "config": { + "author_handle": "alice.bsky.social", + "include_replies": False, + "max_posts_per_fetch": 100, + }, + "is_active": True, + }, + request_only=True, +) + SOURCE_CONFIG_RESPONSE_EXAMPLE = OpenApiExample( "Source Configuration Response", value={ @@ -228,6 +246,16 @@ examples=[AUTHENTICATION_REQUIRED_EXAMPLE], ) +BLUESKY_CREDENTIALS_VERIFY_RESPONSE = inline_serializer( + name="BlueskyCredentialsVerifyResponse", + fields={ + "status": serializers.CharField(), + "handle": serializers.CharField(), + "last_verified_at": serializers.DateTimeField(allow_null=True), + "last_error": serializers.CharField(allow_blank=True), + }, +) + def build_success_response( response, description: str, examples: list[OpenApiExample] | None = None @@ -554,6 +582,80 @@ def get_queryset(self): return self.queryset.filter(group__user=self.request.user).distinct() + @extend_schema( + summary="Verify Bluesky credentials", + description=( + "Verify the selected project's stored Bluesky credentials by authenticating " + "the account and checking the current session." + ), + tags=["Ingestion"], + request=None, + responses={ + 200: build_success_response( + BLUESKY_CREDENTIALS_VERIFY_RESPONSE, + "The project's Bluesky credentials were verified successfully.", + ), + 400: OpenApiResponse( + response=inline_serializer( + name="BlueskyCredentialsVerifyErrorResponse", + fields={ + "type": serializers.CharField(), + "errors": inline_serializer( + name="BlueskyCredentialsVerifyError", + fields={ + "code": serializers.CharField(), + "detail": serializers.CharField(), + "attr": serializers.CharField(allow_null=True), + }, + many=True, + ), + }, + ), + description="The project is missing Bluesky credentials or verification failed.", + ), + 403: AUTHENTICATION_REQUIRED_RESPONSE, + }, + ) + @action(detail=True, methods=["post"], url_path="verify-bluesky-credentials") + def verify_bluesky_credentials(self, request, *args, **kwargs): + """Verify the Bluesky credentials stored for the selected project.""" + + from core.plugins.bluesky import BlueskySourcePlugin + + project = self.get_object() + try: + credentials = project.bluesky_credentials + except BlueskyCredentials.DoesNotExist as exc: + raise serializers.ValidationError( + {"bluesky_credentials": "No Bluesky credentials are configured for this project."} + ) from exc + + try: + BlueskySourcePlugin.verify_credentials(credentials) + except Exception as exc: + logger.exception( + "Bluesky credential verification failed for project id=%s", + project.id, + ) + raise serializers.ValidationError( + { + "bluesky_credentials": ( + "Credential verification failed. Please re-check the credentials " + "and try again." + ) + } + ) from exc + + credentials.refresh_from_db() + return Response( + { + "status": "verified", + "handle": credentials.handle, + "last_verified_at": credentials.last_verified_at, + "last_error": "", + } + ) + @document_project_owned_viewset( resource_plural="project configurations", @@ -741,6 +843,7 @@ class IngestionRunViewSet(ProjectOwnedQuerysetMixin, viewsets.ModelViewSet): create_examples=[ SOURCE_CONFIG_CREATE_REQUEST_EXAMPLE, SOURCE_CONFIG_REDDIT_REQUEST_EXAMPLE, + SOURCE_CONFIG_BLUESKY_REQUEST_EXAMPLE, SOURCE_CONFIG_RESPONSE_EXAMPLE, ], create_response_examples=[SOURCE_CONFIG_RESPONSE_EXAMPLE], diff --git a/core/migrations/0003_rename_core_newsle_project_2c63fb_idx_core_newsle_project_eee7a4_idx_and_more.py b/core/migrations/0003_rename_core_newsle_project_2c63fb_idx_core_newsle_project_eee7a4_idx_and_more.py new file mode 100644 index 00000000..0d84851c --- /dev/null +++ b/core/migrations/0003_rename_core_newsle_project_2c63fb_idx_core_newsle_project_eee7a4_idx_and_more.py @@ -0,0 +1,26 @@ +# Generated by Django 6.0.4 on 2026-04-28 23:38 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0002_newsletter_intake"), + ] + + operations = [ + migrations.RenameIndex( + model_name="newsletterintake", + new_name="core_newsle_project_eee7a4_idx", + old_name="core_newsle_project_2c63fb_idx", + ), + migrations.AlterField( + model_name="sourceconfig", + name="plugin_name", + field=models.CharField( + choices=[("rss", "RSS"), ("reddit", "Reddit"), ("bluesky", "Bluesky")], + max_length=64, + ), + ), + ] diff --git a/core/migrations/0004_blueskycredentials.py b/core/migrations/0004_blueskycredentials.py new file mode 100644 index 00000000..0537e3b2 --- /dev/null +++ b/core/migrations/0004_blueskycredentials.py @@ -0,0 +1,51 @@ +# Generated by Django 6.0.4 on 2026-04-28 23:59 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ( + "core", + "0003_rename_core_newsle_project_2c63fb_idx_core_newsle_project_eee7a4_idx_and_more", + ), + ] + + operations = [ + migrations.CreateModel( + name="BlueskyCredentials", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("handle", models.CharField(max_length=255)), + ("app_password_encrypted", models.TextField(blank=True)), + ("pds_url", models.URLField(blank=True)), + ("is_active", models.BooleanField(default=True)), + ("last_verified_at", models.DateTimeField(blank=True, null=True)), + ("last_error", models.TextField(blank=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "project", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="bluesky_credentials", + to="core.project", + ), + ), + ], + options={ + "verbose_name_plural": "Bluesky credentials", + "ordering": ["project__name"], + }, + ), + ] diff --git a/core/models.py b/core/models.py index a7573687..e509a817 100644 --- a/core/models.py +++ b/core/models.py @@ -5,8 +5,12 @@ of the core entities new contributors interact with first. """ +import base64 +import hashlib import secrets +from urllib.parse import urlsplit, urlunsplit +from cryptography.fernet import Fernet from django.conf import settings from django.contrib.auth.models import Group from django.db import models @@ -34,6 +38,40 @@ def generate_confirmation_token() -> str: return secrets.token_urlsafe(24) +def normalize_bluesky_handle(handle: str) -> str: + """Normalize Bluesky handles so stored account references stay consistent.""" + + return handle.strip().removeprefix("@").lower() + + +def normalize_bluesky_pds_url(pds_url: str) -> str: + """Normalize a user-provided PDS URL to its base host form.""" + + stripped_url = pds_url.strip().rstrip("/") + if not stripped_url: + return "" + parsed_url = urlsplit(stripped_url) + path = parsed_url.path.rstrip("/") + if path.endswith("/xrpc"): + path = path[: -len("/xrpc")] + return urlunsplit( + (parsed_url.scheme, parsed_url.netloc, path, parsed_url.query, parsed_url.fragment) + ).rstrip("/") + + +def _bluesky_credentials_fernet() -> Fernet: + """Build the symmetric cipher used for Bluesky app-password storage.""" + + key_material = ( + getattr(settings, "BLUESKY_CREDENTIALS_ENCRYPTION_KEY", "") + or settings.SECRET_KEY + ) + derived_key = base64.urlsafe_b64encode( + hashlib.sha256(key_material.encode("utf-8")).digest() + ) + return Fernet(derived_key) + + class EntityType(models.TextChoices): """Supported types of tracked entities within a project.""" @@ -63,6 +101,7 @@ class SourcePluginName(models.TextChoices): RSS = "rss", "RSS" REDDIT = "reddit", "Reddit" + BLUESKY = "bluesky", "Bluesky" class NewsletterIntakeStatus(models.TextChoices): @@ -127,6 +166,87 @@ def __str__(self) -> str: return self.name +class BlueskyCredentials(models.Model): + """Stores the authenticated Bluesky account used by one project. + + The plugin can read public content through AppView without credentials, but a + stored account enables authenticated reads and self-hosted PDS support. + """ + + project = models.OneToOneField( + Project, on_delete=models.CASCADE, related_name="bluesky_credentials" + ) + handle = models.CharField(max_length=255) + app_password_encrypted = models.TextField(blank=True) + pds_url = models.URLField(blank=True) + is_active = models.BooleanField(default=True) + last_verified_at = models.DateTimeField(null=True, blank=True) + last_error = models.TextField(blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["project__name"] + verbose_name_plural = "Bluesky credentials" + + def __str__(self) -> str: + return f"Bluesky credentials for {self.project.name}" + + @property + def client_base_url(self) -> str: + """Return the effective base URL used by the ATProto client.""" + + if not self.pds_url: + return "https://bsky.social/xrpc" + return f"{self.pds_url.rstrip('/')}/xrpc" + + def has_app_password(self) -> bool: + """Return whether an encrypted app password has been stored.""" + + return bool(self.app_password_encrypted) + + def has_stored_credential(self) -> bool: + """Return whether an encrypted Bluesky credential has been stored.""" + + return self.has_app_password() + + def set_app_password(self, app_password: str) -> None: + """Encrypt and store the given Bluesky app password.""" + + if not app_password: + self.app_password_encrypted = "" + return + self.app_password_encrypted = _bluesky_credentials_fernet().encrypt( + app_password.encode("utf-8") + ).decode("utf-8") + + def set_stored_credential(self, credential_value: str) -> None: + """Encrypt and store the given Bluesky credential value.""" + + self.set_app_password(credential_value) + + def get_app_password(self) -> str: + """Decrypt and return the stored Bluesky app password.""" + + if not self.app_password_encrypted: + return "" + return _bluesky_credentials_fernet().decrypt( + self.app_password_encrypted.encode("utf-8") + ).decode("utf-8") + + def get_stored_credential(self) -> str: + """Decrypt and return the stored Bluesky credential value.""" + + return self.get_app_password() + + def save(self, *args, **kwargs): + """Normalize stored account fields before persisting the credentials.""" + + self.handle = normalize_bluesky_handle(self.handle) + self.pds_url = normalize_bluesky_pds_url(self.pds_url) + super().save(*args, **kwargs) + + class ProjectConfig(models.Model): """Stores tunable scoring parameters for a single project. diff --git a/core/plugins/base.py b/core/plugins/base.py index 072a0d48..8fe08f0d 100644 --- a/core/plugins/base.py +++ b/core/plugins/base.py @@ -18,6 +18,7 @@ class ContentItem: published_date: datetime content_text: str source_plugin: str + source_metadata: dict[str, object] | None = None class SourcePlugin(ABC): @@ -77,6 +78,15 @@ def match_entity_for_url(self, url: str): return entity return None + def match_entity_for_item(self, item: ContentItem): + """Match a fetched content item to an entity. + + The default implementation preserves the existing hostname-based behavior + by matching against the normalized item URL. + """ + + return self.match_entity_for_url(item.url) + @staticmethod def _normalize_hostname(url: str) -> str: """Normalize a URL hostname for entity matching.""" diff --git a/core/plugins/bluesky.py b/core/plugins/bluesky.py new file mode 100644 index 00000000..0f19c760 --- /dev/null +++ b/core/plugins/bluesky.py @@ -0,0 +1,247 @@ +"""Bluesky source plugin used to ingest public feeds and author timelines.""" + +from __future__ import annotations + +from datetime import datetime + +from atproto import Client +from django.utils import timezone +from django.utils.dateparse import parse_datetime + +from core.models import BlueskyCredentials, SourcePluginName +from core.plugins.base import ContentItem, SourcePlugin + +PUBLIC_APPVIEW_BASE_URL = "https://public.api.bsky.app" + + +class BlueskySourcePlugin(SourcePlugin): + """Fetch public Bluesky feed or author posts through AppView.""" + + @classmethod + def verify_credentials(cls, credentials: BlueskyCredentials) -> None: + """Authenticate a stored Bluesky account and confirm the session works.""" + + try: + client = cls._authenticated_client_for_credentials(credentials) + client.com.atproto.server.get_session() + except Exception as exc: + cls._record_credentials_status(credentials, error_message=str(exc)) + raise + cls._record_credentials_status(credentials, error_message="") + + @classmethod + def validate_config(cls, config: object) -> dict: + """Validate Bluesky feed or author configuration.""" + + normalized_config = super().validate_config(config) + feed_uri = normalized_config.get("feed_uri") + author_handle = normalized_config.get("author_handle") + if bool(feed_uri) == bool(author_handle): + raise ValueError("Provide exactly one of feed_uri or author_handle") + if feed_uri and ( + not isinstance(feed_uri, str) + or not feed_uri.startswith("at://") + or "/app.bsky.feed.generator/" not in feed_uri + ): + raise ValueError( + "feed_uri must be a Bluesky feed generator at:// URI" + ) + if author_handle: + normalized_handle = cls._normalize_handle(author_handle) + if not normalized_handle: + raise ValueError("author_handle must be a non-empty Bluesky handle") + normalized_config["author_handle"] = normalized_handle + + normalized_config["max_posts_per_fetch"] = int( + normalized_config.get("max_posts_per_fetch", 100) + ) + if normalized_config["max_posts_per_fetch"] <= 0: + raise ValueError("max_posts_per_fetch must be a positive integer") + + include_replies = normalized_config.get("include_replies", False) + if not isinstance(include_replies, bool): + raise ValueError("include_replies must be a boolean") + normalized_config["include_replies"] = include_replies + return normalized_config + + def fetch_new_content(self, since: datetime | None) -> list[ContentItem]: + """Fetch public Bluesky posts newer than ``since`` and normalize them.""" + + response = self._get_feed_response() + items: list[ContentItem] = [] + seen_post_uris: set[str] = set() + for feed_view in response.feed: + post = getattr(feed_view, "post", None) + if post is None or post.uri in seen_post_uris: + continue + seen_post_uris.add(post.uri) + if not self.source_config.config.get("include_replies", False) and getattr( + feed_view, "reply", None + ): + continue + published_date = self._published_date_for_post(post) + if since and published_date <= since: + continue + items.append(self._build_content_item(post, published_date)) + return items + + def health_check(self) -> bool: + """Treat the source as healthy when the AppView request succeeds.""" + + credentials = self._credentials() + try: + self._get_feed_response(limit=1) + except Exception as exc: + self._record_credentials_status(credentials, error_message=str(exc)) + raise + self._record_credentials_status(credentials, error_message="") + return True + + def match_entity_for_item(self, item: ContentItem): + """Match posts to entities using the author's Bluesky handle first.""" + + author_handle = self._normalize_handle( + str((item.source_metadata or {}).get("author_handle", "")) + ) + if author_handle: + for entity in self.project.entities.exclude(bluesky_handle=""): + if self._normalize_handle(entity.bluesky_handle) == author_handle: + return entity + return super().match_entity_for_item(item) + + def _get_feed_response(self, limit: int | None = None): + """Query the configured public feed endpoint.""" + + request_limit = limit or self.source_config.config.get("max_posts_per_fetch", 100) + client = self._client() + feed_uri = self.source_config.config.get("feed_uri") + if feed_uri: + return client.app.bsky.feed.get_feed( + {"feed": feed_uri, "limit": request_limit} + ) + return client.app.bsky.feed.get_author_feed( + { + "actor": self.source_config.config["author_handle"], + "include_pins": False, + "limit": request_limit, + } + ) + + def _build_content_item(self, post, published_date: datetime) -> ContentItem: + """Convert one AppView post into the shared plugin payload.""" + + author_handle = self._normalize_handle(self._nested_value(post, "author", "handle")) + external_url = self._nested_value(post, "embed", "external", "uri") + external_title = ( + self._nested_value(post, "embed", "external", "title") or "" + ).strip() + post_url = self._post_url(post) + record_text = (self._nested_value(post, "record", "text") or "").strip() + title = external_title or record_text.splitlines()[0].strip() or post_url + return ContentItem( + url=external_url or post_url, + title=title, + author=author_handle, + published_date=published_date, + content_text=record_text or external_title or post_url, + source_plugin=SourcePluginName.BLUESKY, + source_metadata={ + "author_did": self._nested_value(post, "author", "did") or "", + "author_handle": author_handle, + "embedded_url": external_url or "", + "post_uri": getattr(post, "uri", ""), + "reply_count": getattr(post, "reply_count", 0) or 0, + "repost_count": getattr(post, "repost_count", 0) or 0, + }, + ) + + @staticmethod + def _published_date_for_post(post) -> datetime: + """Choose the indexed or record timestamp for a Bluesky post.""" + + for value in ( + getattr(post, "indexed_at", None), + BlueskySourcePlugin._nested_value(post, "record", "created_at"), + ): + if value: + parsed_value = parse_datetime(value) + if parsed_value is not None: + return parsed_value + return timezone.now() + + @staticmethod + def _post_url(post) -> str: + """Build the public web URL for a Bluesky post when no card link exists.""" + + actor = ( + BlueskySourcePlugin._normalize_handle( + BlueskySourcePlugin._nested_value(post, "author", "handle") + ) + or BlueskySourcePlugin._nested_value(post, "author", "did") + or "" + ) + post_uri = getattr(post, "uri", "") + post_id = post_uri.rstrip("/").split("/")[-1] if post_uri else "" + if actor and post_id: + return f"https://bsky.app/profile/{actor}/post/{post_id}" + return post_uri + + @staticmethod + def _normalize_handle(handle: object) -> str: + """Normalize handles so matching stays case-insensitive.""" + + if not isinstance(handle, str): + return "" + return handle.strip().removeprefix("@").lower() + + @staticmethod + def _nested_value(value, *path: str): + """Read nested object or dict attributes without binding to model types.""" + + current_value = value + for path_part in path: + if current_value is None: + return None + if isinstance(current_value, dict): + current_value = current_value.get(path_part) + else: + current_value = getattr(current_value, path_part, None) + return current_value + + def _client(self) -> Client: + """Create a public or authenticated ATProto client for the project.""" + + credentials = self._credentials() + if credentials is None: + return Client(base_url=PUBLIC_APPVIEW_BASE_URL) + return self._authenticated_client_for_credentials(credentials) + + def _credentials(self) -> BlueskyCredentials | None: + """Return the active project-scoped Bluesky credentials, if configured.""" + + return BlueskyCredentials.objects.filter(project=self.project, is_active=True).first() + + @staticmethod + def _authenticated_client_for_credentials(credentials: BlueskyCredentials) -> Client: + """Build an authenticated client from a stored credential record.""" + + if not credentials.has_app_password(): + raise RuntimeError("Bluesky credentials are missing an app password.") + client = Client(base_url=credentials.client_base_url) + client.login(login=credentials.handle, password=credentials.get_app_password()) + return client + + @staticmethod + def _record_credentials_status( + credentials: BlueskyCredentials | None, *, error_message: str + ) -> None: + """Persist the latest credential verification result when credentials exist.""" + + if credentials is None: + return + update_fields = ["last_error", "updated_at"] + credentials.last_error = error_message + if not error_message: + credentials.last_verified_at = timezone.now() + update_fields.append("last_verified_at") + credentials.save(update_fields=update_fields) diff --git a/core/plugins/registry.py b/core/plugins/registry.py index 9a5db0a0..7bd8ccc4 100644 --- a/core/plugins/registry.py +++ b/core/plugins/registry.py @@ -3,12 +3,14 @@ from typing import Any from core.models import SourcePluginName +from core.plugins.bluesky import BlueskySourcePlugin from core.plugins.reddit import RedditSourcePlugin from core.plugins.rss import RSSSourcePlugin PLUGIN_REGISTRY = { SourcePluginName.RSS: RSSSourcePlugin, SourcePluginName.REDDIT: RedditSourcePlugin, + SourcePluginName.BLUESKY: BlueskySourcePlugin, } diff --git a/core/settings_types.py b/core/settings_types.py index 413570ec..e2cb7bbd 100644 --- a/core/settings_types.py +++ b/core/settings_types.py @@ -2,6 +2,7 @@ class CoreSettings(Protocol): + BLUESKY_CREDENTIALS_ENCRYPTION_KEY: str CELERY_TASK_ALWAYS_EAGER: bool DEFAULT_FROM_EMAIL: str NEWSLETTER_API_BASE_URL: str diff --git a/core/tasks.py b/core/tasks.py index 997958d7..a33d64db 100644 --- a/core/tasks.py +++ b/core/tasks.py @@ -148,17 +148,19 @@ def _ingest_source_config(source_config: SourceConfig) -> tuple[int, int]: fetched_items = plugin.fetch_new_content(source_config.last_fetched_at) ingested_count = 0 for item in fetched_items: - if Content.objects.filter(project=source_config.project, url=item.url).exists(): + if _content_exists_for_item(source_config, item): continue + source_metadata = getattr(item, "source_metadata", None) or {} content = Content.objects.create( project=source_config.project, - entity=plugin.match_entity_for_url(item.url), + entity=_match_entity_for_item(plugin, item), url=item.url, title=item.title[:512], author=item.author[:255], source_plugin=item.source_plugin, published_date=item.published_date, content_text=item.content_text, + source_metadata=source_metadata, ) _schedule_content_processing(content) ingested_count += 1 @@ -167,6 +169,27 @@ def _ingest_source_config(source_config: SourceConfig) -> tuple[int, int]: return len(fetched_items), ingested_count +def _content_exists_for_item(source_config: SourceConfig, item) -> bool: + """Check whether a fetched item already exists for the project.""" + + post_uri = (getattr(item, "source_metadata", None) or {}).get("post_uri") + if post_uri: + return Content.objects.filter( + project=source_config.project, + source_plugin=item.source_plugin, + source_metadata__post_uri=post_uri, + ).exists() + return Content.objects.filter(project=source_config.project, url=item.url).exists() + + +def _match_entity_for_item(plugin, item): + """Resolve the entity for an item while preserving older plugin mocks.""" + + if callable(getattr(type(plugin), "match_entity_for_item", None)): + return plugin.match_entity_for_item(item) + return plugin.match_entity_for_url(item.url) + + @shared_task(name="core.tasks.process_newsletter_intake") def process_newsletter_intake(intake_id: int): """Convert a stored newsletter email into content rows. diff --git a/core/tests/test_admin.py b/core/tests/test_admin.py index a67b853e..ebd9adc7 100644 --- a/core/tests/test_admin.py +++ b/core/tests/test_admin.py @@ -8,6 +8,8 @@ from django.utils import timezone from core.admin import ( + BlueskyCredentialsAdmin, + BlueskyCredentialsAdminForm, ContentAdmin, EntityAdmin, HighValueFilter, @@ -18,6 +20,7 @@ UserFeedbackAdmin, ) from core.models import ( + BlueskyCredentials, Content, Entity, IngestionRun, @@ -185,6 +188,78 @@ def test_review_queue_display_confidence_renders_without_django6_format_error( assert "42%" in rendered +def test_bluesky_credentials_admin_form_encrypts_app_password(source_admin_context): + form = BlueskyCredentialsAdminForm( + data={ + "project": source_admin_context.project.id, + "handle": "@Alice.BSKY.social", + "credential_input": "app-password", + "pds_url": "https://pds.example.com/xrpc/", + "is_active": True, + } + ) + + assert form.is_valid(), form.errors + credentials = form.save() + + assert credentials.handle == "alice.bsky.social" + assert credentials.pds_url == "https://pds.example.com" + assert credentials.has_app_password() is True + assert credentials.get_app_password() == "app-password" + + +def test_verify_selected_bluesky_credentials_reports_success( + source_admin_context, mocker +): + credentials = BlueskyCredentials.objects.create( + project=source_admin_context.project, + handle="alice.bsky.social", + app_password_encrypted="ciphertext", + ) + verify_mock = mocker.patch("core.plugins.bluesky.BlueskySourcePlugin.verify_credentials") + admin_instance = BlueskyCredentialsAdmin(BlueskyCredentials, AdminSite()) + admin_instance.message_user = mocker.Mock() + + admin_instance.verify_selected_credentials( + request=SimpleNamespace(), + queryset=BlueskyCredentials.objects.filter(pk=credentials.pk), + ) + + verify_mock.assert_called_once_with(credentials) + admin_instance.message_user.assert_called_once_with( + ANY, + "Credential verification passed for 1 account(s).", + messages.SUCCESS, + ) + + +def test_verify_selected_bluesky_credentials_reports_failures( + source_admin_context, mocker +): + credentials = BlueskyCredentials.objects.create( + project=source_admin_context.project, + handle="alice.bsky.social", + app_password_encrypted="ciphertext", + ) + mocker.patch( + "core.plugins.bluesky.BlueskySourcePlugin.verify_credentials", + side_effect=RuntimeError("bad login"), + ) + admin_instance = BlueskyCredentialsAdmin(BlueskyCredentials, AdminSite()) + admin_instance.message_user = mocker.Mock() + + admin_instance.verify_selected_credentials( + request=SimpleNamespace(), + queryset=BlueskyCredentials.objects.filter(pk=credentials.pk), + ) + + admin_instance.message_user.assert_called_once_with( + ANY, + "Credential verification failed for: Bluesky credentials for Admin Project: bad login", + messages.ERROR, + ) + + def test_ingestion_run_display_efficiency_renders_without_django6_format_error( source_admin_context, ): diff --git a/core/tests/test_api.py b/core/tests/test_api.py index d63f8b1a..fc099c4a 100644 --- a/core/tests/test_api.py +++ b/core/tests/test_api.py @@ -8,6 +8,7 @@ from rest_framework.test import APITestCase from core.models import ( + BlueskyCredentials, Content, Entity, FeedbackType, @@ -157,6 +158,72 @@ def test_nested_entity_list_rejects_other_users_project(self): self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + def test_verify_bluesky_credentials_requires_project_credentials(self): + response = self.client.post( + reverse( + "v1:project-verify-bluesky-credentials", + kwargs={"id": self.owner_project.id}, + ), + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assert_standardized_validation_error( + response.json(), "bluesky_credentials" + ) + + @patch("core.plugins.bluesky.BlueskySourcePlugin.verify_credentials") + def test_verify_bluesky_credentials_verifies_project_account(self, verify_mock): + credentials = BlueskyCredentials(project=self.owner_project, handle="project.bsky.social") + credentials.set_app_password("app-password") + credentials.save() + + response = self.client.post( + reverse( + "v1:project-verify-bluesky-credentials", + kwargs={"id": self.owner_project.id}, + ), + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + verify_mock.assert_called_once() + verified_credentials = verify_mock.call_args.args[0] + self.assertEqual(verified_credentials.id, credentials.id) + self.assertEqual(response.json()["status"], "verified") + self.assertEqual(response.json()["handle"], "project.bsky.social") + self.assertEqual(response.json()["last_error"], "") + + @patch("core.api.logger.exception") + @patch( + "core.plugins.bluesky.BlueskySourcePlugin.verify_credentials", + side_effect=RuntimeError("bad login"), + ) + def test_verify_bluesky_credentials_surfaces_verification_errors( + self, _verify_mock, logger_exception_mock + ): + credentials = BlueskyCredentials(project=self.owner_project, handle="project.bsky.social") + credentials.set_app_password("app-password") + credentials.save() + + response = self.client.post( + reverse( + "v1:project-verify-bluesky-credentials", + kwargs={"id": self.owner_project.id}, + ), + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assert_standardized_validation_error( + response.json(), "bluesky_credentials" + ) + self.assertNotIn("bad login", str(response.json())) + logger_exception_mock.assert_called_once_with( + "Bluesky credential verification failed for project id=%s", + self.owner_project.id, + ) + def test_feedback_create_assigns_current_user(self): response = self.client.post( reverse( diff --git a/core/tests/test_bluesky.py b/core/tests/test_bluesky.py new file mode 100644 index 00000000..45910176 --- /dev/null +++ b/core/tests/test_bluesky.py @@ -0,0 +1,270 @@ +from datetime import UTC, datetime, timedelta +from types import SimpleNamespace + +import pytest +from django.contrib.auth.models import Group + +from core.models import ( + BlueskyCredentials, + Entity, + Project, + SourceConfig, + SourcePluginName, +) +from core.plugins.bluesky import BlueskySourcePlugin + +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def bluesky_context(django_user_model): + user = django_user_model.objects.create_user( + username="bluesky-owner", password="testpass123" + ) + group = Group.objects.create(name="bluesky-team") + user.groups.add(group) + project = Project.objects.create( + name="Bluesky Project", group=group, topic_description="Infra" + ) + entity = Entity.objects.create( + project=project, + name="Alice", + type="person", + bluesky_handle="alice.bsky.social", + website_url="https://example.com/company", + ) + source_config = SourceConfig.objects.create( + project=project, + plugin_name=SourcePluginName.BLUESKY, + config={"author_handle": "alice.bsky.social"}, + ) + return SimpleNamespace(project=project, entity=entity, source_config=source_config) + + +def test_bluesky_validate_config_normalizes_defaults_and_rejects_invalid_values(): + assert BlueskySourcePlugin.validate_config({"author_handle": "@Alice.BSKY.social"}) == { + "author_handle": "alice.bsky.social", + "include_replies": False, + "max_posts_per_fetch": 100, + } + + assert BlueskySourcePlugin.validate_config( + { + "feed_uri": "at://did:plc:alice/app.bsky.feed.generator/news", + "include_replies": True, + "max_posts_per_fetch": "5", + } + ) == { + "feed_uri": "at://did:plc:alice/app.bsky.feed.generator/news", + "include_replies": True, + "max_posts_per_fetch": 5, + } + + with pytest.raises(ValueError, match="Provide exactly one"): + BlueskySourcePlugin.validate_config({}) + + with pytest.raises(ValueError, match="Provide exactly one"): + BlueskySourcePlugin.validate_config( + { + "feed_uri": "at://did:plc:alice/app.bsky.feed.generator/news", + "author_handle": "alice.bsky.social", + } + ) + + with pytest.raises(ValueError, match="feed_uri must be a Bluesky feed generator"): + BlueskySourcePlugin.validate_config({"feed_uri": "https://example.com/feed"}) + + with pytest.raises(ValueError, match="max_posts_per_fetch must be a positive integer"): + BlueskySourcePlugin.validate_config( + {"author_handle": "alice.bsky.social", "max_posts_per_fetch": 0} + ) + + with pytest.raises(ValueError, match="include_replies must be a boolean"): + BlueskySourcePlugin.validate_config( + {"author_handle": "alice.bsky.social", "include_replies": "yes"} + ) + + +def test_bluesky_fetch_new_content_prefers_embedded_links_and_filters_replies( + bluesky_context, mocker +): + plugin = BlueskySourcePlugin(bluesky_context.source_config) + now = datetime.now(tz=UTC) + iso_now = now.isoformat().replace("+00:00", "Z") + old_iso = (now - timedelta(days=2)).isoformat().replace("+00:00", "Z") + old_post = SimpleNamespace( + uri="at://did:plc:alice/app.bsky.feed.post/old", + indexed_at=old_iso, + author=SimpleNamespace(did="did:plc:alice", handle="Alice.BSky.social"), + record=SimpleNamespace(text="Old post", created_at=old_iso), + reply_count=0, + repost_count=0, + embed=None, + ) + reply_post = SimpleNamespace( + uri="at://did:plc:alice/app.bsky.feed.post/reply", + indexed_at=iso_now, + author=SimpleNamespace(did="did:plc:alice", handle="Alice.BSky.social"), + record=SimpleNamespace(text="A reply", created_at=iso_now), + reply_count=1, + repost_count=0, + embed=None, + ) + linked_post = SimpleNamespace( + uri="at://did:plc:alice/app.bsky.feed.post/fresh", + indexed_at=iso_now, + author=SimpleNamespace(did="did:plc:alice", handle="Alice.BSky.social"), + record=SimpleNamespace(text=" Check this out ", created_at=iso_now), + embed=SimpleNamespace( + external=SimpleNamespace( + uri="https://example.com/article", + title=" Linked article ", + ) + ), + reply_count=2, + repost_count=3, + ) + duplicate_post = SimpleNamespace( + uri="at://did:plc:alice/app.bsky.feed.post/fresh", + indexed_at=iso_now, + author=SimpleNamespace(did="did:plc:alice", handle="Alice.BSky.social"), + record=SimpleNamespace(text="Duplicate", created_at=iso_now), + reply_count=0, + repost_count=0, + embed=None, + ) + mocker.patch.object( + BlueskySourcePlugin, + "_get_feed_response", + return_value=SimpleNamespace( + feed=[ + SimpleNamespace(post=old_post, reply=None), + SimpleNamespace(post=reply_post, reply=object()), + SimpleNamespace(post=linked_post, reply=None), + SimpleNamespace(post=duplicate_post, reply=None), + ] + ), + ) + + items = plugin.fetch_new_content(since=now - timedelta(hours=1)) + + assert len(items) == 1 + assert items[0].url == "https://example.com/article" + assert items[0].title == "Linked article" + assert items[0].author == "alice.bsky.social" + assert items[0].content_text == "Check this out" + assert items[0].source_plugin == SourcePluginName.BLUESKY + assert items[0].source_metadata == { + "author_did": "did:plc:alice", + "author_handle": "alice.bsky.social", + "embedded_url": "https://example.com/article", + "post_uri": "at://did:plc:alice/app.bsky.feed.post/fresh", + "reply_count": 2, + "repost_count": 3, + } + + +def test_bluesky_match_entity_for_item_uses_bluesky_handle(bluesky_context): + plugin = BlueskySourcePlugin(bluesky_context.source_config) + + result = plugin.match_entity_for_item( + SimpleNamespace( + url="https://irrelevant.example.com/article", + source_metadata={"author_handle": "Alice.BSky.social"}, + ) + ) + + assert result == bluesky_context.entity + + +def test_bluesky_health_check_queries_configured_endpoint(bluesky_context, mocker): + client = SimpleNamespace( + app=SimpleNamespace( + bsky=SimpleNamespace( + feed=SimpleNamespace( + get_author_feed=mocker.Mock(return_value=SimpleNamespace(feed=[])) + ) + ) + ) + ) + mocker.patch.object(BlueskySourcePlugin, "_client", return_value=client) + + plugin = BlueskySourcePlugin(bluesky_context.source_config) + + assert plugin.health_check() is True + client.app.bsky.feed.get_author_feed.assert_called_once_with( + {"actor": "alice.bsky.social", "include_pins": False, "limit": 1} + ) + + +def test_bluesky_credentials_encrypt_password_and_normalize_pds_url(bluesky_context): + credentials = BlueskyCredentials( + project=bluesky_context.project, + handle="@Alice.BSKY.social", + pds_url="https://pds.example.com/xrpc/", + ) + credentials.set_app_password("app-password") + credentials.save() + credentials.refresh_from_db() + + assert credentials.handle == "alice.bsky.social" + assert credentials.pds_url == "https://pds.example.com" + assert credentials.client_base_url == "https://pds.example.com/xrpc" + assert credentials.app_password_encrypted != "app-password" + assert credentials.get_app_password() == "app-password" + + +def test_bluesky_client_uses_authenticated_project_credentials( + bluesky_context, mocker +): + credentials = BlueskyCredentials(project=bluesky_context.project, handle="alice.bsky.social") + credentials.set_app_password("app-password") + credentials.save() + client = mocker.Mock() + client_cls = mocker.patch("core.plugins.bluesky.Client", return_value=client) + + plugin = BlueskySourcePlugin(bluesky_context.source_config) + + assert plugin._client() == client + client_cls.assert_called_once_with(base_url="https://bsky.social/xrpc") + client.login.assert_called_once_with( + login="alice.bsky.social", password="app-password" + ) + + +def test_bluesky_health_check_records_credential_errors(bluesky_context, mocker): + credentials = BlueskyCredentials(project=bluesky_context.project, handle="alice.bsky.social") + credentials.set_app_password("app-password") + credentials.save() + plugin = BlueskySourcePlugin(bluesky_context.source_config) + mocker.patch.object( + BlueskySourcePlugin, "_get_feed_response", side_effect=RuntimeError("bad login") + ) + + with pytest.raises(RuntimeError, match="bad login"): + plugin.health_check() + + credentials.refresh_from_db() + assert credentials.last_error == "bad login" + assert credentials.last_verified_at is None + + +def test_bluesky_verify_credentials_uses_authenticated_session_check( + bluesky_context, mocker +): + credentials = BlueskyCredentials(project=bluesky_context.project, handle="alice.bsky.social") + credentials.set_app_password("app-password") + credentials.save() + client = mocker.Mock() + client_cls = mocker.patch("core.plugins.bluesky.Client", return_value=client) + + BlueskySourcePlugin.verify_credentials(credentials) + + client_cls.assert_called_once_with(base_url="https://bsky.social/xrpc") + client.login.assert_called_once_with( + login="alice.bsky.social", password="app-password" + ) + client.com.atproto.server.get_session.assert_called_once_with() + credentials.refresh_from_db() + assert credentials.last_error == "" + assert credentials.last_verified_at is not None diff --git a/core/tests/test_plugin_base.py b/core/tests/test_plugin_base.py index 395be933..dbefc37c 100644 --- a/core/tests/test_plugin_base.py +++ b/core/tests/test_plugin_base.py @@ -102,6 +102,29 @@ def test_source_plugin_match_entity_for_url_returns_none_when_no_entity_matches( assert plugin.match_entity_for_url("https://example.com/posts/123") is None +def test_source_plugin_match_entity_for_item_delegates_to_url_matching(plugin_context): + matching_entity = Entity.objects.create( + project=plugin_context.project, + name="Matching Entity", + type="vendor", + website_url="https://example.com/company", + ) + plugin = DummySourcePlugin(plugin_context.source_config) + + result = plugin.match_entity_for_item( + ContentItem( + url="https://example.com/posts/123", + title="Example", + author="Author", + published_date=datetime(2026, 4, 28, 12, 0, tzinfo=UTC), + content_text="Body", + source_plugin="dummy", + ) + ) + + assert result == matching_entity + + def test_source_plugin_abstract_methods_raise_not_implemented(plugin_context): plugin = DummySourcePlugin(plugin_context.source_config) diff --git a/core/tests/test_serializers.py b/core/tests/test_serializers.py index 6efb2e34..b88bafea 100644 --- a/core/tests/test_serializers.py +++ b/core/tests/test_serializers.py @@ -224,6 +224,29 @@ def test_source_config_serializer_surfaces_plugin_validation_errors(serializer_c assert serializer.errors == {"config": ["Missing required config field: feed_url"]} +def test_source_config_serializer_normalizes_bluesky_author_handle_config( + serializer_context, +): + serializer = SourceConfigSerializer( + data={ + "plugin_name": SourcePluginName.BLUESKY, + "config": {"author_handle": "@Alice.BSKY.social"}, + "is_active": True, + }, + context={ + "request": _request_for(serializer_context.user), + "project": serializer_context.project, + }, + ) + + assert serializer.is_valid(), serializer.errors + assert serializer.validated_data["config"] == { + "author_handle": "alice.bsky.social", + "include_replies": False, + "max_posts_per_fetch": 100, + } + + def test_entity_serializer_filters_project_queryset_to_request_user(serializer_context): serializer = EntitySerializer( context={"request": _request_for(serializer_context.user)} diff --git a/core/tests/test_tasks.py b/core/tests/test_tasks.py index ff16bdfb..5f451520 100644 --- a/core/tests/test_tasks.py +++ b/core/tests/test_tasks.py @@ -161,6 +161,55 @@ def test_run_ingestion_creates_content_from_reddit_posts(source_plugin_context, assert content.entity is None +def test_ingest_source_config_deduplicates_bluesky_posts_by_post_uri( + source_plugin_context, mocker +): + upsert_embedding_mock = mocker.patch("core.tasks.upsert_content_embedding") + process_content_delay_mock = mocker.patch("core.tasks.process_content.delay") + source_config = SourceConfig.objects.create( + project=source_plugin_context.project, + plugin_name=SourcePluginName.BLUESKY, + config={"author_handle": "example.bsky.social"}, + ) + Content.objects.create( + project=source_plugin_context.project, + entity=source_plugin_context.entity, + url="https://example.com/existing-article", + title="Existing Bluesky Post", + author="example.bsky.social", + source_plugin=SourcePluginName.BLUESKY, + published_date="2026-04-20T12:00:00Z", + content_text="Existing content", + source_metadata={"post_uri": "at://did:plc:author/app.bsky.feed.post/abc123"}, + ) + plugin = SimpleNamespace( + fetch_new_content=lambda since: [ + SimpleNamespace( + url="https://example.com/new-canonical-url", + title="Duplicate Bluesky Post", + author="example.bsky.social", + published_date=datetime(2026, 4, 20, 12, 0, tzinfo=timezone.utc), + content_text="Duplicate content", + source_plugin=SourcePluginName.BLUESKY, + source_metadata={ + "author_handle": "example.bsky.social", + "post_uri": "at://did:plc:author/app.bsky.feed.post/abc123", + }, + ) + ], + match_entity_for_item=lambda item: source_plugin_context.entity, + ) + mocker.patch("core.tasks.get_plugin_for_source_config", return_value=plugin) + + items_fetched, items_ingested = _ingest_source_config(source_config) + + assert items_fetched == 1 + assert items_ingested == 0 + assert Content.objects.filter(project=source_plugin_context.project).count() == 1 + upsert_embedding_mock.assert_not_called() + process_content_delay_mock.assert_not_called() + + def test_run_all_ingestions_enqueues_active_source_configs( source_plugin_context, mocker ): diff --git a/newsletter_maker/settings/__init__.py b/newsletter_maker/settings/__init__.py index e6b64bb6..73efc4f0 100644 --- a/newsletter_maker/settings/__init__.py +++ b/newsletter_maker/settings/__init__.py @@ -30,6 +30,7 @@ AUTH_PASSWORD_VALIDATORS, AUTHENTICATION_BACKENDS, BASE_DIR, + BLUESKY_CREDENTIALS_ENCRYPTION_KEY, CSRF_TRUSTED_ORIGINS, DATABASE_URL, DATABASES, @@ -93,6 +94,7 @@ "AUTHENTICATION_BACKENDS", "AUTH_PASSWORD_VALIDATORS", "BASE_DIR", + "BLUESKY_CREDENTIALS_ENCRYPTION_KEY", "CELERY_BEAT_SCHEDULE", "CELERY_BROKER_URL", "CELERY_RESULT_BACKEND", diff --git a/newsletter_maker/settings/base.py b/newsletter_maker/settings/base.py index cdd1b359..f5b6554b 100644 --- a/newsletter_maker/settings/base.py +++ b/newsletter_maker/settings/base.py @@ -42,6 +42,9 @@ def env_list(name: str, default: str = "") -> list[str]: DATABASE_URL = os.getenv("DATABASE_URL", f"sqlite:///{BASE_DIR / 'db.sqlite3'}") SITE_ID = int(os.getenv("SITE_ID", "1")) NEWSLETTER_API_BASE_URL = os.getenv("NEWSLETTER_API_BASE_URL", "http://127.0.0.1:8080") +BLUESKY_CREDENTIALS_ENCRYPTION_KEY = os.getenv( + "BLUESKY_CREDENTIALS_ENCRYPTION_KEY", "" +) REDDIT_CLIENT_ID = os.getenv("REDDIT_CLIENT_ID", "") REDDIT_CLIENT_SECRET = os.getenv("REDDIT_CLIENT_SECRET", "") @@ -194,6 +197,7 @@ def env_list(name: str, default: str = "") -> list[str]: "DATABASE_URL", "SITE_ID", "NEWSLETTER_API_BASE_URL", + "BLUESKY_CREDENTIALS_ENCRYPTION_KEY", "REDDIT_CLIENT_ID", "REDDIT_CLIENT_SECRET", "REDDIT_USER_AGENT",