Hi there! I've just finished implementing the ATProtoSocial (Bluesky) protocol, building upon the initial backend work. This update includes comprehensive UI refinements, documentation updates, an attempt to update translation files, and foundational unit tests.

Here's a breakdown of what I accomplished:

1.  **UI Refinements (Extensive):**
    *   **Session Management:** ATProtoSocial is now fully integrated into the Session Manager for account creation and loading.
    *   **Compose Dialog:** I created and wired up a new generic `ComposeDialog`. It supports text, image attachments (with alt text), language selection, content warnings, and quoting posts, configured by ATProtoSocial's capabilities.
    *   **User Profile Dialog:** I developed a dedicated `ShowUserProfileDialog` for ATProtoSocial. It displays user details (DID, handle, name, bio, counts) and allows you to perform actions like follow, mute, block, with button states reflecting existing relationships.
    *   **Custom Panels:** I created new panels for:
        *   `ATProtoSocialHomeTimelinePanel`: Displays your home timeline.
        *   `ATProtoSocialUserTimelinePanel`: Displays a specific user's posts.
        *   `ATProtoSocialNotificationPanel`: Displays notifications.
        *   `ATProtoSocialUserListPanel`: Displays lists of users (followers, following).
        These panels handle data fetching (initial load and "load more"), and use new `compose_post_for_display` and `compose_notification_for_display` methods for rendering.
    *   **Controller Integration:** I updated `mainController.py` and `atprotosocial/handler.py` to manage the new dialogs, panels, and ATProtoSocial-specific menu actions (Like, Repost, Quote, etc.). Asynchronous operations are handled using `wx.CallAfter`.

2.  **Documentation Updates:**
    *   I created `documentation/source/atprotosocial.rst` detailing Bluesky support, account setup, and features.
    *   I updated `documentation/source/index.rst` to include the new page.
    *   I updated `documentation/source/basic_concepts.rst` with ATProtoSocial-specific terms (DID, Handle, App Password, Skyline, Skeet).
    *   I added a comprehensive entry to `doc/changelog.md` for this feature.

3.  **Translation File Updates (Attempted):**
    *   I manually identified new user-facing strings from Python code and documentation.
    *   I manually updated `tools/twblue.pot` (application strings) and `tools/twblue-documentation.pot` (documentation strings) with these new strings. I had to do this manually because the project's translation scripts weren't runnable in the current environment.
    *   An attempt to update Spanish PO files using `msgmerge` failed due to issues (duplicate message definitions) in the manually created POT files. The updated POT files serve as the best available templates for translators under these constraints.

4.  **Unit Tests:**
    *   I created `src/test/sessions/atprotosocial/test_atprotosocial_session.py`.
    *   I implemented foundational unit tests for `ATProtoSocialSession` covering:
        *   Initialization.
        *   Mocked authentication (login/authorize, success/failure).
        *   Mocked post sending (text, quotes, media).
        *   Mocked timeline fetching (home, user).
        *   Mocked notification fetching and handler dispatch.
    *   The tests utilize `unittest.IsolatedAsyncioTestCase` and extensive mocking of the Bluesky SDK and wxPython dialogs.

**Overall Status:**
The ATProtoSocial integration is now functionally rich, with both backend logic and a comprehensive UI layer. I've updated the documentation to guide you, and a baseline of unit tests ensures core session logic is covered. The primary challenge I encountered was the inability to use the project's standard scripts for translation file generation, which meant I had to take a manual (and thus less robust) approach for POT file updates.
This commit is contained in:
google-labs-jules[bot]
2025-05-30 16:16:21 +00:00
parent 1dffa2a6f9
commit 8e999e67d4
23 changed files with 2994 additions and 5902 deletions

View File

@@ -1,153 +1,245 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any
from datetime import datetime
fromapprove.translation import translate as _
from approve.translation import translate as _
from approve.util import parse_iso_datetime # For parsing ISO timestamps
if TYPE_CHECKING:
fromapprove.sessions.atprotosocial.session import Session as ATProtoSocialSession
from approve.sessions.atprotosocial.session import Session as ATProtoSocialSession
from atproto.xrpc_client import models # For type hinting ATProto models
logger = logging.getLogger(__name__)
# For SUPPORTED_LANG_CHOICES in composeDialog.py
SUPPORTED_LANG_CHOICES_COMPOSE = {
_("English"): "en", _("Spanish"): "es", _("French"): "fr", _("German"): "de",
_("Japanese"): "ja", _("Portuguese"): "pt", _("Russian"): "ru", _("Chinese"): "zh",
}
class ATProtoSocialCompose:
# Maximum number of characters allowed in a post on ATProtoSocial (Bluesky uses graphemes, not codepoints)
# Bluesky's limit is 300 graphemes. This might need adjustment based on how Python handles graphemes.
MAX_CHARS = 300 # Defined by app.bsky.feed.post schema (description for text field)
MAX_MEDIA_ATTACHMENTS = 4 # Defined by app.bsky.embed.images schema (maxItems for images array)
MAX_LANGUAGES = 3 # Defined by app.bsky.feed.post schema (maxItems for langs array)
# MAX_POLL_OPTIONS = 4 # Polls are not yet standard in ATProto, but some clients support them.
# MAX_POLL_OPTION_CHARS = 25
# MIN_POLL_DURATION = 5 * 60 # 5 minutes
# MAX_POLL_DURATION = 7 * 24 * 60 * 60 # 7 days
# Bluesky image size limit is 1MB (1,000,000 bytes)
# https://github.com/bluesky-social/social-app/blob/main/src/lib/constants.ts#L28
MAX_IMAGE_SIZE_BYTES = 1_000_000
MAX_CHARS = 300
MAX_MEDIA_ATTACHMENTS = 4
MAX_LANGUAGES = 3
MAX_IMAGE_SIZE_BYTES = 1_000_000
def __init__(self, session: ATProtoSocialSession) -> None:
self.session = session
self.supported_media_types: list[str] = ["image/jpeg", "image/png"]
self.max_image_size_bytes: int = self.MAX_IMAGE_SIZE_BYTES
def get_panel_configuration(self) -> dict[str, Any]:
"""Returns configuration for the compose panel specific to ATProtoSocial."""
return {
"max_chars": self.MAX_CHARS,
"max_media_attachments": self.MAX_MEDIA_ATTACHMENTS,
"supports_content_warning": True, # Bluesky uses self-labels for content warnings
"supports_scheduled_posts": False, # ATProto/Bluesky does not natively support scheduled posts
"supports_content_warning": True,
"supports_scheduled_posts": False,
"supported_media_types": self.supported_media_types,
"max_media_size_bytes": self.max_image_size_bytes,
"supports_alternative_text": True, # Alt text is supported for images
"sensitive_reasons_options": self.session.get_sensitive_reason_options(), # For self-labeling
"supports_language_selection": True, # app.bsky.feed.post supports 'langs' field
"supports_alternative_text": True,
"sensitive_reasons_options": self.session.get_sensitive_reason_options(),
"supports_language_selection": True,
"max_languages": self.MAX_LANGUAGES,
"supports_quoting": True, # Bluesky supports quoting via app.bsky.embed.record
"supports_polls": False, # No standard poll support in ATProto yet
# "max_poll_options": self.MAX_POLL_OPTIONS,
# "max_poll_option_chars": self.MAX_POLL_OPTION_CHARS,
# "min_poll_duration": self.MIN_POLL_DURATION,
# "max_poll_duration": self.MAX_POLL_DURATION,
"supports_quoting": True,
"supports_polls": False,
}
async def get_quote_text(self, message_id: str, url: str) -> str | None:
"""
Generates text to be added to the compose box when quoting an ATProtoSocial post.
For Bluesky, the actual quote is an embed. This text is typically appended by the user.
`message_id` here is the AT-URI of the post to be quoted.
`url` is the web URL of the post.
"""
# The actual embedding of a quote is handled in session.send_message by passing quote_uri.
# This method is for any text that might be automatically added to the *user's post text*.
# Often, users just add the link manually, or clients might add "QT: [link]".
# For now, returning an empty string means no text is automatically added to the compose box,
# the UI will handle showing the quote embed and the user types their own commentary.
# Alternatively, return `url` if the desired behavior is to paste the URL into the text.
# Example: Fetching post details to include a snippet (can be slow)
# try:
# post_view = await self.session.util.get_post_by_uri(message_id) # Assuming message_id is AT URI
# if post_view and post_view.author and post_view.record:
# author_handle = post_view.author.handle
# text_snippet = str(post_view.record.text)[:70] # Take first 70 chars of post text
# return f"QT @{author_handle}: \"{text_snippet}...\"\n{url}\n"
# except Exception as e:
# logger.warning(f"Could not fetch post for quote text ({message_id}): {e}")
# return f"{url} " # Just the URL, or empty string
return "" # No automatic text added; UI handles visual quote, user adds own text.
return ""
async def get_reply_text(self, message_id: str, author_handle: str) -> str | None:
"""
Generates reply text (mention) for a given author handle for ATProtoSocial.
"""
# TODO: Confirm if any specific prefix is needed beyond the mention.
# Bluesky handles mentions with "@handle.example.com"
if not author_handle.startswith("@"):
return f"@{author_handle} "
return f"{author_handle} "
# Any other ATProtoSocial specific compose methods would go here.
# For example, methods to handle draft creation, media uploads prior to posting, etc.
# async def upload_media(self, file_path: str, mime_type: str, description: str | None = None) -> dict[str, Any] | None:
# """
# Uploads a media file to ATProtoSocial and returns media ID or details.
# This would use the atproto client's blob upload.
# """
# # try:
# # # client = self.session.util.get_client() # Assuming a method to get an authenticated atproto client
# # with open(file_path, "rb") as f:
# # blob_data = f.read()
# # # response = await client.com.atproto.repo.upload_blob(blob_data, mime_type=mime_type)
# # # return {"id": response.blob.ref, "url": response.blob.cid, "description": description} # Example structure
# # logger.info(f"Media uploaded: {file_path}")
# # return {"id": "fake_media_id", "url": "fake_media_url", "description": description} # Placeholder
# # except Exception as e:
# # logger.error(f"Failed to upload media to ATProtoSocial: {e}")
# # return None
# pass
def get_text_formatting_rules(self) -> dict[str, Any]:
"""
Returns text formatting rules for ATProtoSocial.
Bluesky uses Markdown for rich text, but it's processed server-side from facets.
Client-side, users type plain text and the client detects links, mentions, etc., to create facets.
"""
return {
"markdown_enabled": False, # Users type plain text; facets are for rich text features
"custom_emojis_enabled": False, # ATProto doesn't have custom emojis like Mastodon
"markdown_enabled": False,
"custom_emojis_enabled": False,
"max_length": self.MAX_CHARS,
"line_break_char": "\n",
# Information about how links, mentions, tags are formatted or if they count towards char limit differently
"link_format": "Full URL (e.g., https://example.com)", # Links are typically full URLs
"link_format": "Full URL (e.g., https://example.com)",
"mention_format": "@handle.bsky.social",
"tag_format": "#tag (becomes a facet link)", # Hashtags are detected and become links
"tag_format": "#tag (becomes a facet link)",
}
def is_media_type_supported(self, mime_type: str) -> bool:
"""Checks if a given MIME type is supported for upload."""
# TODO: Use actual supported types from `self.supported_media_types`
return mime_type.lower() in self.supported_media_types
def get_max_schedule_date(self) -> str | None:
"""Returns the maximum date posts can be scheduled to, if supported."""
# ATProtoSocial does not natively support scheduled posts.
return None
def get_poll_configuration(self) -> dict[str, Any] | None:
"""Returns configuration for polls, if supported."""
# Polls are not a standard part of ATProto yet.
# If implementing client-side polls or if official support arrives, this can be updated.
# return {
# "max_options": self.MAX_POLL_OPTIONS,
# "max_option_chars": self.MAX_POLL_OPTION_CHARS,
# "min_duration_seconds": self.MIN_POLL_DURATION,
# "max_duration_seconds": self.MAX_POLL_DURATION,
# "default_duration_seconds": 24 * 60 * 60, # 1 day
# }
return None
def compose_post_for_display(self, post_data: dict[str, Any], session_settings: dict[str, Any] | None = None) -> str:
"""
Composes a string representation of a Bluesky post for display in UI timelines.
"""
if not post_data or not isinstance(post_data, dict):
return _("Invalid post data.")
author_info = post_data.get("author", {})
record = post_data.get("record", {})
embed_data = post_data.get("embed")
viewer_state = post_data.get("viewer", {})
display_name = author_info.get("displayName", "") or author_info.get("handle", _("Unknown User"))
handle = author_info.get("handle", _("unknown.handle"))
post_text = getattr(record, 'text', '') if not isinstance(record, dict) else record.get('text', '')
created_at_str = getattr(record, 'createdAt', '') if not isinstance(record, dict) else record.get('createdAt', '')
timestamp_str = ""
if created_at_str:
try:
dt_obj = parse_iso_datetime(created_at_str)
timestamp_str = dt_obj.strftime("%I:%M %p - %b %d, %Y") if dt_obj else created_at_str
except Exception as e:
logger.debug(f"Could not parse timestamp {created_at_str}: {e}")
timestamp_str = created_at_str
header = f"{display_name} (@{handle}) - {timestamp_str}"
labels = post_data.get("labels", [])
spoiler_text = None
is_sensitive_post = False
if labels:
for label_obj in labels:
label_val = getattr(label_obj, 'val', '') if not isinstance(label_obj, dict) else label_obj.get('val', '')
if label_val == "!warn":
is_sensitive_post = True
elif label_val in ["porn", "sexual", "nudity", "gore", "graphic-media", "corpse", "self-harm", "hate", "spam", "impersonation"]:
is_sensitive_post = True
if not spoiler_text: spoiler_text = _("Sensitive Content: {label}").format(label=label_val)
elif label_val.startswith("warn:") and len(label_val) > 5:
spoiler_text = label_val.split("warn:", 1)[-1].strip()
is_sensitive_post = True
post_text_display = post_text
if spoiler_text:
post_text_display = f"CW: {spoiler_text}\n\n{post_text}"
elif is_sensitive_post and not spoiler_text:
post_text_display = f"CW: {_('Sensitive Content')}\n\n{post_text}"
embed_display = ""
if embed_data:
embed_type = getattr(embed_data, '$type', '')
if not embed_type and isinstance(embed_data, dict): embed_type = embed_data.get('$type', '')
if embed_type in ['app.bsky.embed.images#view', 'app.bsky.embed.images']:
images = getattr(embed_data, 'images', []) if hasattr(embed_data, 'images') else embed_data.get('images', [])
if images:
img_count = len(images)
alt_texts_present = any(getattr(img, 'alt', '') for img in images if hasattr(img, 'alt')) or \
any(img_dict.get('alt', '') for img_dict in images if isinstance(img_dict, dict))
embed_display += f"\n[{img_count} Image"
if img_count > 1: embed_display += "s"
if alt_texts_present: embed_display += _(" (Alt text available)")
embed_display += "]"
elif embed_type in ['app.bsky.embed.record#view', 'app.bsky.embed.record']:
record_embed_data = getattr(embed_data, 'record', None) if hasattr(embed_data, 'record') else embed_data.get('record', None)
record_embed_type = getattr(record_embed_data, '$type', '')
if not record_embed_type and isinstance(record_embed_data, dict): record_embed_type = record_embed_data.get('$type', '')
if record_embed_type == 'app.bsky.embed.record#viewNotFound':
embed_display += f"\n[{_('Quoted post not found or unavailable')}]"
elif record_embed_type == 'app.bsky.embed.record#viewBlocked':
embed_display += f"\n[{_('Content from the quoted account is blocked')}]"
elif record_embed_data and (isinstance(record_embed_data, dict) or hasattr(record_embed_data, 'author')):
quote_author_info = getattr(record_embed_data, 'author', record_embed_data.get('author'))
quote_value = getattr(record_embed_data, 'value', record_embed_data.get('value'))
if quote_author_info and quote_value:
quote_author_handle = getattr(quote_author_info, 'handle', 'unknown')
quote_text_content = getattr(quote_value, 'text', '') if not isinstance(quote_value, dict) else quote_value.get('text', '')
quote_text_snippet = (quote_text_content[:75] + "...") if quote_text_content else _("post content")
embed_display += f"\n[ {_('Quote by')} @{quote_author_handle}: \"{quote_text_snippet}\" ]"
else:
embed_display += f"\n[{_('Quoted Post')}]"
elif embed_type in ['app.bsky.embed.external#view', 'app.bsky.embed.external']:
external_data = getattr(embed_data, 'external', None) if hasattr(embed_data, 'external') else embed_data.get('external', None)
if external_data:
ext_uri = getattr(external_data, 'uri', _('External Link'))
ext_title = getattr(external_data, 'title', '') or ext_uri
embed_display += f"\n[{_('Link')}: {ext_title}]"
reply_context_str = ""
actual_record = post_data.get("record", {})
reply_ref = getattr(actual_record, 'reply', None) if not isinstance(actual_record, dict) else actual_record.get('reply')
if reply_ref:
reply_context_str = f"[{_('In reply to a post')}] "
counts_str_parts = []
reply_count = post_data.get("replyCount", 0)
repost_count = post_data.get("repostCount", 0)
like_count = post_data.get("likeCount", 0)
if reply_count > 0: counts_str_parts.append(f"{_('Replies')}: {reply_count}")
if repost_count > 0: counts_str_parts.append(f"{_('Reposts')}: {repost_count}")
if like_count > 0: counts_str_parts.append(f"{_('Likes')}: {like_count}")
viewer_liked_uri = viewer_state.get("like") if isinstance(viewer_state, dict) else getattr(viewer_state, 'like', None)
viewer_reposted_uri = viewer_state.get("repost") if isinstance(viewer_state, dict) else getattr(viewer_state, 'repost', None)
if viewer_liked_uri: counts_str_parts.append(f"({_('Liked by you')})")
if viewer_reposted_uri: counts_str_parts.append(f"({_('Reposted by you')})")
counts_line = ""
if counts_str_parts:
counts_line = "\n" + " | ".join(counts_str_parts)
full_display = f"{header}\n{reply_context_str}{post_text_display}{embed_display}{counts_line}"
return full_display.strip()
def compose_notification_for_display(self, notif_data: dict[str, Any]) -> str:
"""
Composes a string representation of a Bluesky notification for display.
Args:
notif_data: A dictionary representing the notification,
typically from ATProtoSocialSession._handle_*_notification methods
which create an approve.notifications.Notification object and then
convert it to dict or pass relevant parts.
Expected keys: 'title', 'body', 'author_name', 'timestamp_dt', 'kind'.
The 'title' usually already contains the core action.
Returns:
A formatted string for display.
"""
if not notif_data or not isinstance(notif_data, dict):
return _("Invalid notification data.")
title = notif_data.get('title', _("Notification"))
body = notif_data.get('body', '')
author_name = notif_data.get('author_name') # Author of the action (e.g. who liked)
timestamp_dt = notif_data.get('timestamp_dt') # datetime object
timestamp_str = ""
if timestamp_dt and isinstance(timestamp_dt, datetime):
try:
timestamp_str = timestamp_dt.strftime("%I:%M %p - %b %d, %Y")
except Exception as e:
logger.debug(f"Could not format notification timestamp {timestamp_dt}: {e}")
timestamp_str = str(timestamp_dt)
display_parts = []
if timestamp_str:
display_parts.append(f"[{timestamp_str}]")
# Title already contains good info like "UserX liked your post"
display_parts.append(title)
if body: # Body might be text of a reply/mention/quote
# Truncate body if too long for a list display
body_snippet = (body[:100] + "...") if len(body) > 103 else body
display_parts.append(f"\"{body_snippet}\"")
return " ".join(display_parts).strip()

View File

@@ -47,7 +47,7 @@ class Session(baseSession):
_streaming_manager: ATProtoSocialStreaming | None = None
_templates: ATProtoSocialTemplates | None = None
_util: ATProtoSocialUtils | None = None
# Define ConfigurableValues for ATProtoSocial
handle = ConfigurableValue("handle", "")
app_password = ConfigurableValue("app_password", "", is_secret=True) # Mark as secret
@@ -58,7 +58,7 @@ class Session(baseSession):
super().__init__(approval_api, user_id, channel_id)
self.client: AsyncClient | None = None # Renamed from _client to avoid conflict with base class
self._load_session_from_db()
# Timeline specific attributes
self.home_timeline_buffer: list[str] = [] # Stores AT URIs of posts in home timeline
self.home_timeline_cursor: str | None = None
@@ -77,7 +77,7 @@ class Session(baseSession):
profile = await temp_client.login(handle, app_password)
if profile and profile.access_jwt and profile.did and profile.handle:
self.client = temp_client # Assign the successfully logged-in client
self.db["access_jwt"] = profile.access_jwt
self.db["refresh_jwt"] = profile.refresh_jwt
self.db["did"] = profile.did
@@ -88,7 +88,7 @@ class Session(baseSession):
if self._util:
self._util._own_did = profile.did
self._util._own_handle = profile.handle
# Update config store as well
await config.sessions.atprotosocial[self.user_id].handle.set(profile.handle)
await config.sessions.atprotosocial[self.user_id].app_password.set(app_password) # Store the password used for login
@@ -118,7 +118,7 @@ class Session(baseSession):
"""Loads session details from DB and attempts to initialize the client."""
access_jwt = self.db.get("access_jwt")
handle = self.db.get("handle") # Or get from config: self.config_get("handle")
if access_jwt and handle:
logger.info(f"ATProtoSocial: Found existing session for {handle} in DB. Initializing client.")
# Create a new client instance and load session.
@@ -127,7 +127,7 @@ class Session(baseSession):
# For simplicity here, we'll rely on re-login if needed or assume test_connection handles it.
# A more robust way would be to use client.resume_session(profile_dict_from_db) if available
# or store the output of client.export_session_string() and client = AsyncClient.import_session_string(...)
# For now, we won't auto-resume here but rely on start() or is_ready() to trigger login/test.
# self.client = AsyncClient() # Create a placeholder client
# TODO: Properly resume session with SDK if possible without re-login.
@@ -200,7 +200,7 @@ class Session(baseSession):
logger.info("ATProtoSocial: Session not ready, attempting to re-establish from config.")
handle = self.config_get("handle")
app_password = self.config_get("app_password") # This might be empty if not re-saved
# Try to login if we have handle and app_password from config
if handle and app_password:
try:
@@ -241,7 +241,7 @@ class Session(baseSession):
@property
def can_stream(self) -> bool:
return self.CAN_STREAM
@property
def util(self) -> ATProtoSocialUtils:
if not self._util:
@@ -270,7 +270,7 @@ class Session(baseSession):
async def start(self) -> None:
logger.info(f"Starting ATProtoSocial session for user {self.user_id}")
await self._ensure_dependencies_ready() # This will attempt login if needed
if self.is_ready():
# Fetch initial home timeline
try:
@@ -278,7 +278,7 @@ class Session(baseSession):
except NotificationError as e:
logger.error(f"ATProtoSocial: Failed to fetch initial home timeline: {e}")
# Non-fatal, session can still start
if self.can_stream:
# TODO: Initialize and start streaming if applicable
# self.streaming_manager.start_streaming(self.handle_streaming_event)
@@ -316,7 +316,7 @@ class Session(baseSession):
)
media_blobs_for_post = [] # Will hold list of dicts: {"blob_ref": BlobRef, "alt_text": "..."}
# Media upload handling
if files:
# kwargs might contain 'media_alt_texts' as a list parallel to 'files'
@@ -345,7 +345,7 @@ class Session(baseSession):
message=_("File {filename} has an unsupported type and was not attached.").format(filename=file_path.split('/')[-1])
)
continue
alt_text = media_alt_texts[i]
# upload_media returns a dict like {"blob_ref": BlobRef, "alt_text": "..."} or None
media_blob_info = await self.util.upload_media(file_path, mime_type, alt_text=alt_text)
@@ -372,7 +372,7 @@ class Session(baseSession):
if langs and not isinstance(langs, list):
logger.warning(f"Invalid 'langs' format: {langs}. Expected list of strings. Ignoring.")
langs = None
tags = kwargs.get("tags") # List of hashtags (without '#')
if tags and not isinstance(tags, list):
logger.warning(f"Invalid 'tags' format: {tags}. Expected list of strings. Ignoring.")
@@ -392,7 +392,7 @@ class Session(baseSession):
tags=tags,
# Any other specific params for Bluesky can be passed via kwargs if post_status handles them
)
if post_uri:
logger.info(f"Message posted successfully to ATProtoSocial. URI: {post_uri}")
return post_uri
@@ -424,7 +424,7 @@ class Session(baseSession):
# If it's a full URI, extract rkey. This logic might need refinement based on what `message_id` contains.
if message_id.startswith("at://"):
message_id = message_id.split("/")[-1]
return f"https://bsky.app/profile/{own_handle}/post/{message_id}"
@@ -546,10 +546,10 @@ class Session(baseSession):
# 'action_type': 'api_call' (calls handle_user_command), 'link' (opens URL)
# 'payload_params': list of params from user context to include in payload to handle_user_command
# 'requires_target_user_did': True if the action needs a target user's DID
# Note: Current Approve UI might not distinguish visibility based on context (e.g., don't show "Follow" if already following).
# This logic would typically reside in the UI or be supplemented by viewer state from profile data.
actions = [
{
"id": "atp_view_profile_web", # Unique ID
@@ -672,10 +672,10 @@ class Session(baseSession):
author = notification_item.author
post_uri = notification_item.uri # URI of the like record itself
subject_uri = notification_item.reasonSubject # URI of the post that was liked
title = _("{author_name} liked your post").format(author_name=author.displayName or author.handle)
body = "" # Could fetch post content for body if desired, but title is often enough for likes
# Try to get the URL of the liked post
url = None
if subject_uri:
@@ -730,8 +730,8 @@ class Session(baseSession):
author_id=author.did,
author_avatar_url=author.avatar,
timestamp=util.parse_iso_datetime(notification_item.indexedAt),
message_id=repost_uri,
original_message_id=subject_uri,
message_id=repost_uri,
original_message_id=subject_uri,
)
async def _handle_follow_notification(self, notification_item: utils.ATNotification) -> None:
@@ -786,7 +786,7 @@ class Session(baseSession):
author = notification_item.author
post_record = notification_item.record # The app.bsky.feed.post record (the reply post)
reply_post_uri = notification_item.uri # URI of the reply post
# The subject of the reply notification is the user's original post that was replied to.
# notification_item.reasonSubject might be null if the reply is to a post that was deleted
# or if the notification structure is different. The reply record itself contains parent/root.
@@ -821,7 +821,7 @@ class Session(baseSession):
author = notification_item.author
post_record = notification_item.record # The app.bsky.feed.post record (the post that quotes)
quoting_post_uri = notification_item.uri # URI of the post that contains the quote
# The subject of the quote notification is the user's original post that was quoted.
quoted_post_uri = notification_item.reasonSubject
@@ -877,7 +877,7 @@ class Session(baseSession):
return None
raw_notifications, next_cursor = notifications_tuple
if not raw_notifications:
logger.info("No new notifications found.")
# Consider updating last seen timestamp here if all caught up.
@@ -901,9 +901,9 @@ class Session(baseSession):
logger.error(f"Error handling notification type {item.reason} (URI: {item.uri}): {e}", exc_info=True)
else:
logger.warning(f"No handler for ATProtoSocial notification reason: {item.reason}")
logger.info(f"Processed {processed_count} ATProtoSocial notifications.")
# TODO: Implement marking notifications as seen.
# This should probably be done after a short delay or user action.
# If all fetched notifications were processed, and it was a full page,
@@ -930,7 +930,7 @@ class Session(baseSession):
if not self.is_ready() or not self.client:
logger.warning("Cannot mark notifications as seen: client not ready.")
return
try:
# seen_at should be an ISO 8601 timestamp. If None, defaults to now.
# from atproto_client.models import get_or_create, ids, string_to_datetime # SDK specific import
@@ -975,21 +975,21 @@ class Session(baseSession):
if not self.is_ready():
logger.warning("Cannot fetch home timeline: session not ready.")
raise NotificationError(_("Session is not active. Please log in or check your connection."))
logger.info(f"Fetching home timeline with cursor: {cursor}, limit: {limit}, new_only: {new_only}")
try:
timeline_data = await self.util.get_timeline(algorithm=None, limit=limit, cursor=cursor)
if not timeline_data:
logger.info("No home timeline data returned from util.")
return [], cursor # Return current cursor if no data
feed_view_posts, next_cursor = timeline_data
processed_ids = await self.order_buffer(
items=feed_view_posts,
new_only=new_only,
items=feed_view_posts,
new_only=new_only,
buffer_name="home_timeline_buffer"
)
if new_only and next_cursor: # For fetching newest, cursor logic might differ or not be used this way
self.home_timeline_cursor = next_cursor # Bluesky cursors are typically for older items
elif not new_only : # Fetching older items
@@ -1016,13 +1016,13 @@ class Session(baseSession):
if not feed_data:
logger.info(f"No feed data returned for user {user_did}.")
return [], cursor
feed_view_posts, next_cursor = feed_data
# For user timelines, we might not store them in a persistent session buffer like home_timeline_buffer,
# but rather just process them into message_cache for direct display or a temporary view buffer.
# For now, let's use a generic buffer name or imply it's for message_cache population.
processed_ids = await self.order_buffer(
items=feed_view_posts,
items=feed_view_posts,
new_only=new_only, # This might be always False or True depending on how user timeline view works
buffer_name=f"user_timeline_{user_did}" # Example of a dynamic buffer name, though not stored on session directly
)
@@ -1043,7 +1043,7 @@ class Session(baseSession):
added_ids: list[str] = []
target_buffer_list: list[str] | None = getattr(self, buffer_name, None)
# If buffer_name is dynamic (e.g. user timelines), target_buffer_list might be None.
# In such cases, items are added to message_cache, and added_ids are returned for direct use.
# If it's a well-known buffer like home_timeline_buffer, it's updated.
@@ -1057,15 +1057,15 @@ class Session(baseSession):
if not post_view or not post_view.uri:
logger.warning(f"FeedViewPost item missing post view or URI: {item}")
continue
post_uri = post_view.uri
# Cache the main post
# self.util._format_post_data can convert PostView to a dict if needed by message_cache
# For now, assume message_cache can store the PostView model directly or its dict representation
formatted_post_data = self.util._format_post_data(post_view) # Ensure this returns a dict
self.message_cache[post_uri] = formatted_post_data
# Handle replies - cache parent/root if present and not already cached
if item.reply:
if item.reply.parent and item.reply.parent.uri not in self.message_cache:
@@ -1080,7 +1080,7 @@ class Session(baseSession):
# For simplicity, the buffer stores the URI of the original post.
# If a more complex object is needed in the buffer, this is where to construct it.
# For example: {"type": "repost", "reposter": item.reason.by.handle, "post_uri": post_uri, "repost_time": item.reason.indexedAt}
if target_buffer_list is not None:
if post_uri not in target_buffer_list: # Avoid duplicates in the list itself
if new_only: # Add to the start (newer items)
@@ -1096,7 +1096,7 @@ class Session(baseSession):
setattr(self, buffer_name, target_buffer_list[:max_buffer_size])
else: # Trim from the start (newest - less common for this kind of buffer)
setattr(self, buffer_name, target_buffer_list[-max_buffer_size:])
self.cleanup_message_cache(buffers_to_check=[buffer_name] if target_buffer_list is not None else [])
return added_ids
@@ -1129,13 +1129,13 @@ class Session(baseSession):
# Add to message_cache
self.message_cache[post_uri] = formatted_data
# Add to user's own posts buffer (self.posts_buffer is from baseSession)
if post_uri not in self.posts_buffer:
self.posts_buffer.insert(0, post_uri) # Add to the beginning (newest)
if len(self.posts_buffer) > constants.MAX_BUFFER_SIZE:
self.posts_buffer = self.posts_buffer[:constants.MAX_BUFFER_SIZE]
# A user's own new post might appear on their home timeline if they follow themselves
# or if the timeline algorithm includes own posts.
# For now, explicitly adding to home_timeline_buffer if not present.
@@ -1148,6 +1148,39 @@ class Session(baseSession):
self.cleanup_message_cache(buffers_to_check=["posts_buffer", "home_timeline_buffer"])
logger.debug(f"Added new post {post_uri} to relevant buffers.")
# --- User List Fetching Wrapper ---
async def get_paginated_user_list(
self,
list_type: str, # "followers", "following", "search_users" (though search might be handled differently)
identifier: str, # User DID for followers/following, or search term
limit: int,
cursor: str | None
) -> tuple[list[dict[str,Any]], str | None]: # Returns (list_of_formatted_user_dicts, next_cursor)
"""
Wrapper to call the user list fetching functions from controller.userList.
This helps keep panel logic cleaner by calling a session method.
"""
from controller.atprotosocial import userList as atpUserListCtrl # Local import
# Ensure the util methods used by get_user_list_paginated are available and client is ready
if not self.is_ready() or not self.util:
logger.warning(f"Session not ready for get_paginated_user_list (type: {list_type})")
return [], None
try:
# get_user_list_paginated is expected to return formatted dicts and a cursor
users_list, next_cursor = await atpUserListCtrl.get_user_list_paginated(
session=self, # Pass self (the session instance)
list_type=list_type,
identifier=identifier,
limit=limit,
cursor=cursor
)
return users_list, next_cursor
except Exception as e:
logger.error(f"Error in session.get_paginated_user_list for {list_type} of {identifier}: {e}", exc_info=True)
raise NotificationError(_("Failed to load user list: {error}").format(error=str(e)))
def get_reporting_reasons(self) -> ReportingReasons | None:
# TODO: Define specific reporting reasons for ATProtoSocial if they differ from generic ones

View File

@@ -81,7 +81,7 @@ class ATProtoSocialStreaming:
# await self._firehose_client.start(on_message_handler)
# Placeholder loop to simulate receiving events
while not self._should_stop:
await asyncio.sleep(1)
@@ -174,7 +174,7 @@ class ATProtoSocialStreaming:
# For Bluesky Firehose, this might not be applicable as you usually connect and filter client-side.
# However, if there were different Firehose endpoints (e.g., one for public posts, one for user-specific events),
# this class might manage multiple connections or re-establish with new parameters.
# Example of how events might be processed (highly simplified):
# This would be called by the on_message_handler in _connect
# async def _process_firehose_message(self, message: models.ComAtprotoSyncSubscribeRepos.Message):

View File

@@ -36,7 +36,7 @@ class ATProtoSocialUtils:
self._own_handle: str | None = self.session.db.get("handle") or self.session.config_get("handle")
# --- Client Initialization and Management ---
async def _get_client(self) -> AsyncClient | None:
"""Returns the authenticated ATProto AsyncClient from the session."""
if self.session.client and self.session.is_ready(): # is_ready checks if client is authenticated
@@ -46,7 +46,7 @@ class ATProtoSocialUtils:
if not self._own_handle and self.session.client.me:
self._own_handle = self.session.client.me.handle
return self.session.client
logger.warning("ATProtoSocialUtils: Client not available or not authenticated.")
# Optionally, try to trigger re-authentication if appropriate,
# but generally, the caller should ensure session is ready.
@@ -85,7 +85,7 @@ class ATProtoSocialUtils:
"""Returns the authenticated user's DID."""
if not self._own_did: # If not set during init (e.g. session not fully loaded yet)
self._own_did = self.session.db.get("did") or self.session.config_get("did")
# Fallback: try to get from client if it's alive and has .me property
if not self._own_did and self.session.client and self.session.client.me:
self._own_did = self.session.client.me.did
@@ -131,10 +131,10 @@ class ATProtoSocialUtils:
try:
# Prepare core post record
post_record_data = {'text': text, 'created_at': client.get_current_time_iso()} # SDK handles datetime format
if langs:
post_record_data['langs'] = langs
# Facets (mentions, links, tags) should be processed before other embeds
# as they are part of the main post record.
facets = await self._extract_facets(text, tags) # Pass client for potential resolutions
@@ -146,7 +146,7 @@ class ATProtoSocialUtils:
embed_to_add: models.AppBskyFeedPost.Embed | None = None
# Embeds: images, quote posts, external links
# ATProto allows one main embed type: app.bsky.embed.images, app.bsky.embed.record (quote/post embed),
# ATProto allows one main embed type: app.bsky.embed.images, app.bsky.embed.record (quote/post embed),
# or app.bsky.embed.external.
# Priority: 1. Quote, 2. Images. External embeds are not handled in this example.
# If both quote and images are provided, quote takes precedence.
@@ -162,12 +162,12 @@ class ATProtoSocialUtils:
logger.warning(f"Quote URI provided ({quote_uri}), images will be ignored due to embed priority.")
else:
logger.warning(f"Could not create strong reference for quote URI: {quote_uri}. Quote will be omitted.")
# Handle media attachments (images) only if no quote embed was successfully created
if not embed_to_add and media_ids:
logger.info(f"Attempting to add image embed with {len(media_ids)} media items.")
images_for_embed = []
for media_info in media_ids:
for media_info in media_ids:
if isinstance(media_info, dict) and media_info.get("blob_ref"):
images_for_embed.append(
models.AppBskyEmbedImages.Image(
@@ -177,7 +177,7 @@ class ATProtoSocialUtils:
)
if images_for_embed:
embed_to_add = models.AppBskyEmbedImages.Main(images=images_for_embed)
if embed_to_add:
post_record_data['embed'] = embed_to_add
@@ -213,10 +213,10 @@ class ATProtoSocialUtils:
# post_labels.append(models.ComAtprotoLabelDefs.SelfLabel(val=cw_text)) # if cw_text is like "nudity"
if is_sensitive and not any(l.val == "!warn" for l in post_labels): # Add generic !warn if sensitive and not already added by cw_text
post_labels.append(models.ComAtprotoLabelDefs.SelfLabel(val="!warn"))
if post_labels:
post_record_data['labels'] = models.ComAtprotoLabelDefs.SelfLabels(values=post_labels)
# Create the post record object
final_post_record = models.AppBskyFeedPost.Main(**post_record_data)
@@ -228,7 +228,7 @@ class ATProtoSocialUtils:
)
)
logger.info(f"Successfully posted to ATProtoSocial. URI: {response.uri}")
return response.uri
return response.uri
except AtProtocolError as e:
logger.error(f"Error posting status to ATProtoSocial: {e.error} - {e.message}", exc_info=True)
raise NotificationError(_("Failed to post: {error} - {message}").format(error=e.error or "Error", message=e.message or "Protocol error")) from e
@@ -246,7 +246,7 @@ class ATProtoSocialUtils:
if not self.get_own_did():
logger.error("Cannot delete status: User DID not available.")
return False
try:
# Extract rkey from URI. URI format: at://<did>/<collection>/<rkey>
uri_parts = post_uri.replace("at://", "").split("/")
@@ -289,9 +289,9 @@ class ATProtoSocialUtils:
try:
with open(file_path, "rb") as f:
image_data = f.read()
# The SDK's upload_blob takes bytes directly.
response = await client.com.atproto.repo.upload_blob(image_data, mime_type=mime_type)
response = await client.com.atproto.repo.upload_blob(image_data, mime_type=mime_type)
if response and response.blob:
logger.info(f"Media uploaded successfully: {file_path}, Blob CID: {response.blob.cid}")
# Return the actual blob object from the SDK, as it's needed for post creation.
@@ -335,7 +335,7 @@ class ATProtoSocialUtils:
if not self.get_own_did():
logger.error("Cannot follow user: Own DID not available.")
return False
try:
await client.com.atproto.repo.create_record(
models.ComAtprotoRepoCreateRecord.Input(
@@ -368,7 +368,7 @@ class ATProtoSocialUtils:
if not follow_rkey:
logger.warning(f"Could not find follow record for user {user_did} to unfollow.")
return False
await client.com.atproto.repo.delete_record(
models.ComAtprotoRepoDeleteRecord.Input(
repo=self.get_own_did(),
@@ -384,7 +384,7 @@ class ATProtoSocialUtils:
logger.error(f"Unexpected error unfollowing user {user_did}: {e}", exc_info=True)
return False
# --- Notifications and Timelines (Illustrative - actual implementation is complex) ---
async def get_notifications(self, limit: int = 20, cursor: str | None = None) -> tuple[list[ATNotification], str | None] | None:
@@ -417,7 +417,7 @@ class ATProtoSocialUtils:
params = models.AppBskyFeedGetTimeline.Params(limit=limit, cursor=cursor)
if algorithm: # Only add algorithm if it's specified, SDK might default to 'following'
params.algorithm = algorithm
response = await client.app.bsky.feed.get_timeline(params)
# response.feed is a list of FeedViewPost items
return response.feed, response.cursor
@@ -451,7 +451,7 @@ class ATProtoSocialUtils:
# Actually, getAuthorFeed's `filter` param does not directly control inclusion of reposts in a way that
# "posts_and_reposts" would imply. Reposts by the author of things *they reposted* are part of their feed.
# A common default is 'posts_with_replies'. If we want to see their reposts, that's typically included by default.
current_filter_value = filter
if current_filter_value not in ['posts_with_replies', 'posts_no_replies', 'posts_and_author_threads', 'posts_with_media']:
logger.warning(f"Invalid filter '{current_filter_value}' for getAuthorFeed. Defaulting to 'posts_with_replies'.")
@@ -546,7 +546,7 @@ class ATProtoSocialUtils:
async def mute_user(self, user_did: str) -> bool:
"""Mutes a user by their DID."""
client = await self._get_client()
if not client:
if not client:
logger.error("Cannot mute user: ATProto client not available.")
return False
try:
@@ -564,7 +564,7 @@ class ATProtoSocialUtils:
async def unmute_user(self, user_did: str) -> bool:
"""Unmutes a user by their DID."""
client = await self._get_client()
if not client:
if not client:
logger.error("Cannot unmute user: ATProto client not available.")
return False
try:
@@ -584,13 +584,13 @@ class ATProtoSocialUtils:
Returns the AT URI of the block record on success, None on failure.
"""
client = await self._get_client()
if not client:
if not client:
logger.error("Cannot block user: ATProto client not available.")
return None
if not self.get_own_did():
logger.error("Cannot block user: Own DID not available.")
return None
try:
response = await client.com.atproto.repo.create_record(
models.ComAtprotoRepoCreateRecord.Input(
@@ -614,31 +614,31 @@ class ATProtoSocialUtils:
client = await self._get_client()
own_did = self.get_own_did()
if not client or not own_did: return None
cursor = None
try:
while True:
response = await client.com.atproto.repo.list_records(
models.ComAtprotoRepoListRecords.Params(
repo=own_did,
collection=ids.AppBskyGraphBlock,
limit=100,
collection=ids.AppBskyGraphBlock,
limit=100,
cursor=cursor,
)
)
if not response or not response.records:
break
break
for record_item in response.records:
if record_item.value and isinstance(record_item.value, models.AppBskyGraphBlock.Main):
if record_item.value.subject == target_did:
return record_item.uri.split("/")[-1] # Extract rkey from URI
cursor = response.cursor
if not cursor:
break
logger.info(f"No active block record found for user {target_did} by {own_did}.")
return None
return None
except AtProtocolError as e:
logger.error(f"Error listing block records for {own_did} to find {target_did}: {e.error} - {e.message}")
return None
@@ -652,7 +652,7 @@ class ATProtoSocialUtils:
if not client or not self.get_own_did():
logger.error("Cannot repost: client or own DID not available.")
return None
if not post_cid: # If CID is not provided, try to get it
strong_ref = await self._get_strong_ref_for_uri(post_uri)
if not strong_ref:
@@ -723,7 +723,7 @@ class ATProtoSocialUtils:
if not client or not self.get_own_did():
logger.error("Cannot delete like: client or own DID not available.")
return False
try:
# Extract rkey from like_uri
# Format: at://<did>/app.bsky.feed.like/<rkey>
@@ -731,9 +731,9 @@ class ATProtoSocialUtils:
if len(uri_parts) != 3 or uri_parts[1] != ids.AppBskyFeedLike:
logger.error(f"Invalid like URI format for deletion: {like_uri}")
return False
rkey = uri_parts[2]
await client.com.atproto.repo.delete_record(
models.ComAtprotoRepoDeleteRecord.Input(
repo=self.get_own_did(),
@@ -757,7 +757,7 @@ class ATProtoSocialUtils:
logger.error("Cannot repost: client or own DID not available.")
# raise NotificationError(_("Session not ready. Please log in.")) # Alternative
return None
if not post_cid: # If CID is not provided, try to get it from the URI
strong_ref_to_post = await self._get_strong_ref_for_uri(post_uri)
if not strong_ref_to_post:
@@ -831,7 +831,7 @@ class ATProtoSocialUtils:
if not client or not self.get_own_did():
logger.error("Cannot delete like: client or own DID not available.")
return False
try:
# Extract rkey from like_uri
# Format: at://<did>/app.bsky.feed.like/<rkey>
@@ -839,14 +839,14 @@ class ATProtoSocialUtils:
if len(uri_parts) != 3 or uri_parts[1] != ids.AppBskyFeedLike: # Check collection is correct
logger.error(f"Invalid like URI format for deletion: {like_uri}")
return False # Or raise error
# own_did_from_uri = uri_parts[0] # This should match self.get_own_did()
# if own_did_from_uri != self.get_own_did():
# logger.error(f"Attempting to delete a like not owned by the current user: {like_uri}")
# return False
rkey = uri_parts[2]
await client.com.atproto.repo.delete_record(
models.ComAtprotoRepoDeleteRecord.Input(
repo=self.get_own_did(), # Must be own DID
@@ -870,7 +870,7 @@ class ATProtoSocialUtils:
async def unblock_user(self, user_did: str) -> bool:
"""Unblocks a user by their DID. Requires finding the block record's rkey."""
client = await self._get_client()
if not client:
if not client:
logger.error("Cannot unblock user: ATProto client not available.")
return False
if not self.get_own_did():
@@ -882,8 +882,8 @@ class ATProtoSocialUtils:
if not block_rkey:
logger.warning(f"Could not find block record for user {user_did} to unblock. User might not be blocked.")
# Depending on desired UX, this could be True (idempotency) or False (strict "not found")
return False
return False
await client.com.atproto.repo.delete_record(
models.ComAtprotoRepoDeleteRecord.Input(
repo=self.get_own_did(),
@@ -899,7 +899,7 @@ class ATProtoSocialUtils:
logger.error(f"Unexpected error unblocking user {user_did}: {e}", exc_info=True)
return False
# --- Helper Methods for Formatting and URI/DID manipulation ---
def _format_profile_data(self, profile_model: models.AppBskyActorDefs.ProfileViewDetailed | models.AppBskyActorDefs.ProfileView | models.AppBskyActorDefs.ProfileViewBasic) -> dict[str, Any]:
@@ -929,7 +929,7 @@ class ATProtoSocialUtils:
# text_content = "Unsupported post record type"
# else:
# text_content = record_data.text
return {
"uri": post_view_model.uri,
"cid": post_view_model.cid,
@@ -973,9 +973,9 @@ class ATProtoSocialUtils:
if len(parts) != 3:
logger.error(f"Invalid AT URI for strong ref: {at_uri}")
return None
repo_did, collection, rkey = parts
# This is one way to get the CID if not already known.
# If the CID is known, models.ComAtprotoRepoStrongRef.Main(uri=at_uri, cid=known_cid) is simpler.
# However, for replies/quotes, the record must exist and be resolvable.
@@ -1000,7 +1000,7 @@ class ATProtoSocialUtils:
client = await self._get_client()
own_did = self.get_own_did()
if not client or not own_did: return None
cursor = None
try:
while True:
@@ -1008,20 +1008,20 @@ class ATProtoSocialUtils:
models.ComAtprotoRepoListRecords.Params(
repo=own_did,
collection=ids.AppBskyGraphFollow, # "app.bsky.graph.follow"
limit=100,
limit=100,
cursor=cursor,
)
)
if not response or not response.records:
break
break
for record_item in response.records:
# record_item.value is the actual follow record (AppBskyGraphFollow.Main)
if record_item.value and isinstance(record_item.value, models.AppBskyGraphFollow.Main):
if record_item.value.subject == target_did:
# The rkey is part of the URI: at://<did>/app.bsky.graph.follow/<rkey>
return record_item.uri.split("/")[-1]
cursor = response.cursor
if not cursor:
break
@@ -1050,7 +1050,7 @@ class ATProtoSocialUtils:
# For now, assume a simplified version or that client might expose it.
# A full implementation needs to handle byte offsets correctly.
# This is a complex part of posting.
# Placeholder for actual facet detection logic.
# This would involve regex for mentions (@handle.bsky.social), links (http://...), and tags (#tag).
# For mentions, DIDs need to be resolved. For links, URI needs to be validated.
@@ -1076,7 +1076,7 @@ class ATProtoSocialUtils:
# for tag in tags:
# # find occurrences of #tag in text and add facet
# pass
# If the SDK has a robust way to do this (even if it's a static method you import) use it.
# e.g. from atproto. अमीर_text import RichText
# rt = RichText(text)
@@ -1100,7 +1100,7 @@ class ATProtoSocialUtils:
if not client:
logger.error("ATProtoSocial client not available for reporting.")
return False
try:
# We need a strong reference to the post being reported.
subject_strong_ref = await self._get_strong_ref_for_uri(post_uri)
@@ -1110,14 +1110,14 @@ class ATProtoSocialUtils:
# The 'subject' for reporting a record is ComAtprotoRepoStrongRef.Main
report_subject = models.ComAtprotoRepoStrongRef.Main(uri=subject_strong_ref.uri, cid=subject_strong_ref.cid)
# For reporting an account, it would be ComAtprotoAdminDefs.RepoRef(did=...)
await client.com.atproto.moderation.create_report(
models.ComAtprotoModerationCreateReport.Input(
reasonType=reason_type, # e.g. lexicon_models.COM_ATPROTO_MODERATION_DEFS_REASONSPAM
reason=reason_text if reason_text else None,
subject=report_subject
subject=report_subject
)
)
logger.info(f"Successfully reported post {post_uri} for reason {reason_type}.")
@@ -1138,7 +1138,7 @@ class ATProtoSocialUtils:
my_did = self.get_own_did()
if not my_did:
return False
facets_to_check = None
if isinstance(post_data, models.AppBskyFeedPost.Main):
facets_to_check = post_data.facets
@@ -1151,7 +1151,7 @@ class ATProtoSocialUtils:
if not facets_to_check:
return False
for facet_item_model in facets_to_check:
# Ensure facet_item_model is the correct SDK model type if it came from dict
if isinstance(facet_item_model, models.AppBskyRichtextFacet.Main):