diff --git a/CAMBIOS.md b/CAMBIOS.md new file mode 100644 index 00000000..edc00094 --- /dev/null +++ b/CAMBIOS.md @@ -0,0 +1,228 @@ +# Integración de Bluesky en TWBlue + +Este documento describe la implementación completa de soporte para Bluesky (AT Protocol) en TWBlue. + +## Resumen + +Se ha añadido soporte completo para la red social Bluesky, siguiendo la arquitectura MVC existente de TWBlue y manteniendo paridad de características con la implementación de Mastodon. + +## Dependencias + +- **atproto**: SDK oficial de Python para AT Protocol (Bluesky) +- Las dependencias existentes de TWBlue (wxPython, arrow, configobj, etc.) + +## Estructura de Archivos + +### Sesión (`src/sessions/blueski/`) + +| Archivo | Descripción | +|---------|-------------| +| `__init__.py` | Inicialización del módulo | +| `session.py` | Clase principal de sesión Bluesky con autenticación, API y gestión de cuenta | +| `compose.py` | Funciones de composición para formatear datos de API en cadenas legibles | +| `streaming.py` | Sistema de polling para actualizaciones en tiempo real | +| `utils.py` | Utilidades auxiliares (detección de medios, etc.) | + +### Controladores de Buffers (`src/controller/buffers/blueski/`) + +| Archivo | Descripción | +|---------|-------------| +| `__init__.py` | Registro de tipos de buffer | +| `base.py` | Buffer base con funcionalidad común (acciones, menús, eventos) | +| `timeline.py` | Buffers de línea de tiempo (Home, Following, Notifications, etc.) | +| `user.py` | Buffers de usuarios (Followers, Following, Blocks) | +| `chat.py` | Buffers de mensajes directos | + +### Controladores (`src/controller/blueski/`) + +| Archivo | Descripción | +|---------|-------------| +| `__init__.py` | Inicialización del módulo | +| `handler.py` | Handler principal que crea buffers y gestiona acciones | +| `messages.py` | Visor de posts con detalles completos | +| `userActions.py` | Diálogo de acciones de usuario (seguir, silenciar, bloquear) | +| `userList.py` | Gestión de listas de usuarios | +| `settings.py` | Configuración de cuenta | +| `templateEditor.py` | Editor de plantillas | + +### Interfaz de Usuario (`src/wxUI/`) + +#### Paneles (`src/wxUI/buffers/blueski/panels.py`) + +- `HomePanel`: Panel de línea de tiempo con botones Post, Repost, Reply, Like, Chat +- `NotificationPanel`: Panel de notificaciones (hereda de HomePanel) +- `UserPanel`: Panel de lista de usuarios con botones Post, Actions, Message +- `ChatPanel`: Panel de lista de conversaciones +- `ChatMessagePanel`: Panel de mensajes de chat individuales + +#### Diálogos (`src/wxUI/dialogs/blueski/`) + +| Archivo | Descripción | +|---------|-------------| +| `postDialogs.py` | Diálogo de composición de posts y visor de posts | +| `configuration.py` | Diálogo de configuración de cuenta | +| `menus.py` | Menús contextuales | +| `userActions.py` | Diálogo de acciones de usuario | +| `showUserProfile.py` | Diálogo de perfil de usuario | + +### Configuración (`src/blueski.defaults`) + +Archivo de configuración por defecto con secciones: +- `[blueski]`: Credenciales (handle, app_password, did, session_string) +- `[general]`: Tiempos relativos, posts por llamada, orden de timelines +- `[sound]`: Configuración de audio +- `[other_buffers]`: Timelines adicionales, búsquedas guardadas +- `[mysc]`: Idioma de corrector ortográfico +- `[reporting]`: Reportes de braille/voz +- `[templates]`: Plantillas de visualización + +## Características Implementadas + +### Autenticación +- Login con handle y App Password de Bluesky +- Persistencia de sesión mediante session_string +- Migración automática de configuración legacy + +### Buffers Disponibles + +1. **Home (Following)**: Timeline cronológico inverso de cuentas seguidas +2. **Discover**: Feed algorítmico de descubrimiento +3. **Mentions**: Menciones, respuestas y citas +4. **Notifications**: Todas las notificaciones (likes, reposts, follows, etc.) +5. **Sent**: Posts enviados por el usuario +6. **Likes**: Posts marcados como favoritos +7. **Followers**: Lista de seguidores +8. **Following**: Lista de seguidos +9. **Blocked Users**: Usuarios bloqueados +10. **Chats**: Mensajes directos +11. **Timelines de Usuario**: Timelines personalizados por usuario +12. **Búsquedas**: Búsquedas guardadas + +### Acciones de Posts +- Publicar nuevo post (con imágenes, CW, idioma) +- Responder a posts +- Repostear +- Dar like +- Eliminar posts propios +- Ver conversación/hilo completo +- Copiar enlace al portapapeles +- Ver detalles del post (likes, reposts, descripción de imágenes) + +### Acciones de Usuario +- Seguir/Dejar de seguir +- Silenciar/Desilenciar +- Bloquear/Desbloquear +- Ver perfil +- Ver timeline de usuario +- Ver seguidores/seguidos de usuario +- Enviar mensaje directo + +### Mensajes Directos +- Lista de conversaciones +- Ver mensajes de una conversación +- Enviar mensajes + +### Sistema de Actualización +- Polling periódico para notificaciones (configurable, mínimo 30 segundos) +- Publicación de eventos via pub/sub para consistencia con Mastodon + +### Accesibilidad +- Lectura automática de nuevos elementos +- Indicadores de sonido para audio/video e imágenes +- Tiempos relativos actualizados al enfocar +- Soporte completo de lector de pantalla + +## Formato de Visualización + +El formato de posts y notificaciones se ha igualado al de Mastodon: + +### Posts +``` +Usuario (@handle), Texto del post, Fecha, Bluesky +``` + +Para reposts: +``` +Reposteador (@handle), Reposted from @original: Texto, Fecha, Bluesky +``` + +### Notificaciones +``` +Usuario (@handle), {usuario} ha añadido a favoritos: {texto}, Fecha +``` + +### Usuarios +``` +Nombre (@handle). X seguidores, Y siguiendo, Z posts. Se unió fecha +``` + +## Sonidos + +| Acción | Sonido | +|--------|--------| +| Nuevo post en timeline | `tweet_received.ogg` | +| Nueva notificación | `notification_received.ogg` | +| Nueva mención | `mention_received.ogg` | +| Nuevo DM | `dm_received.ogg` | +| Post enviado | `tweet_send.ogg` | +| Respuesta enviada | `reply_send.ogg` | +| Repost | `retweet_send.ogg` | +| Like | `favourite.ogg` | +| DM enviado | `dm_sent.ogg` | +| Actualización de seguidores | `update_followers.ogg` | +| Búsqueda/Conversación | `search_updated.ogg` | + +## Notas Técnicas + +### Diferencias con Mastodon +- Bluesky usa AT Protocol en lugar de ActivityPub +- Autenticación mediante App Password en lugar de OAuth2 +- No hay "Firehose" streaming real; se usa polling +- URIs en formato `at://did:plc:xxx/app.bsky.feed.post/rkey` +- Facets para menciones y enlaces en lugar de HTML + +### Manejo de Errores +- Logging consistente con `log.error()` para errores esperados +- Mensajes de error traducibles para el usuario + +### Configuración +- Valores por defecto en `blueski.defaults` +- Acceso a configuración via `self.session.settings` +- Helper `get_max_items()` para obtener límite de posts + +## Pruebas + +Tests ubicados en `src/test/sessions/blueski/`: +- `test_blueski_session.py`: Tests de la sesión de Bluesky + +## Traducciones + +Todos los strings de interfaz usan la función `_()` para internacionalización: +- Títulos de buffers +- Acciones de menú +- Mensajes de confirmación +- Mensajes de error +- Formatos de notificación + +## Archivos Modificados (Fuera de blueski/) + +Para integrar Bluesky en TWBlue, también se modificaron: + +- `src/controller/mainController.py`: Registro de handlers de sesión +- `src/sessionmanager/`: Soporte para tipo de sesión "blueski" +- `src/requirements.txt`: Dependencia atproto + +## Uso + +1. En TWBlue, ir a Sesión > Nueva cuenta +2. Seleccionar "Bluesky" +3. Introducir handle (ej: `usuario.bsky.social`) +4. Introducir App Password (desde Configuración > App Passwords en bsky.app) +5. Los buffers se crearán automáticamente + +## Limitaciones Conocidas + +- No hay streaming en tiempo real (se usa polling) +- Las listas de Bluesky no están implementadas aún +- Los starter packs no están soportados +- La moderación de contenido es básica (solo CW) diff --git a/src/controller/blueski/handler.py b/src/controller/blueski/handler.py index e6b31672..f1bef1c2 100644 --- a/src/controller/blueski/handler.py +++ b/src/controller/blueski/handler.py @@ -33,7 +33,7 @@ class Handler: unfav="HIDE", view=_("&Show post"), view_conversation=_("View conversa&tion"), - ocr="HIDE", + ocr=_("&OCR"), delete=_("&Delete"), # User menu follow=_("&Actions..."), diff --git a/src/controller/blueski/messages.py b/src/controller/blueski/messages.py index e7ce1570..cb81eaee 100644 --- a/src/controller/blueski/messages.py +++ b/src/controller/blueski/messages.py @@ -327,3 +327,17 @@ class viewPost(base_messages.basicMessage): ) except Exception: pass + + +class text(base_messages.basicMessage): + """Simple text viewer dialog for OCR results and similar.""" + + def __init__(self, title, text="", *args, **kwargs): + self.title = title + self.message = postDialogs.viewText(title=title, text=text, *args, **kwargs) + self.message.text.SetInsertionPoint(len(self.message.text.GetValue())) + widgetUtils.connect_event(self.message.spellcheck, widgetUtils.BUTTON_PRESSED, self.spellcheck) + widgetUtils.connect_event(self.message.translateButton, widgetUtils.BUTTON_PRESSED, self.translate) + + def text_processor(self): + pass diff --git a/src/controller/buffers/blueski/base.py b/src/controller/buffers/blueski/base.py index 5bbc62c7..593a9770 100644 --- a/src/controller/buffers/blueski/base.py +++ b/src/controller/buffers/blueski/base.py @@ -605,11 +605,72 @@ class BaseBuffer(base.Buffer): output.speak(_("Could not delete."), True) - def audio(self, *args, **kwargs): - output.speak(_("Audio playback not supported for Bluesky yet.")) - - # Helper to map standard keys if they don't invoke the methods above via get_event - # But usually get_event is enough. + def audio(self, event=None, item=None, *args, **kwargs): + """Play audio/video from the current post.""" + if sound.URLPlayer.player.is_playing(): + return sound.URLPlayer.stop_audio() + if item is None: + item = self.get_item() + if not item: + return + urls = utils.get_media_urls(item) + if not urls: + output.speak(_("This post has no playable media."), True) + return + url = "" + if len(urls) == 1: + url = urls[0] + elif len(urls) > 1: + from wxUI.dialogs import urlList + urls_list = urlList.urlList() + urls_list.populate_list(urls) + if urls_list.get_response() == widgetUtils.OK: + url = urls_list.get_string() + if hasattr(urls_list, "destroy"): + urls_list.destroy() + if url: + sound.URLPlayer.play(url, self.session.settings["sound"]["volume"]) + + def ocr_image(self, *args, **kwargs): + """Perform OCR on images in the current post.""" + post = self.get_item() + if not post: + return + + image_list = utils.get_image_urls(post) + if not image_list: + return + + if len(image_list) > 1: + from wxUI.dialogs import urlList + labels = [_("Picture {0}").format(i + 1) for i in range(len(image_list))] + dialog = urlList.urlList(title=_("Select the picture")) + dialog.populate_list(labels) + if dialog.get_response() != widgetUtils.OK: + return + img = image_list[dialog.get_item()] + else: + img = image_list[0] + + url = img.get("url") + if not url: + return + + from extra import ocr as ocr_module + api = ocr_module.OCRSpace.OCRSpaceAPI() + try: + text = api.OCR_URL(url) + except ocr_module.OCRSpace.APIError: + output.speak(_("Unable to extract text"), True) + return + except Exception as e: + log.error("OCR error: %s", e) + output.speak(_("Unable to extract text"), True) + return + + viewer = blueski_messages.text(title=_("OCR Result"), text=text["ParsedText"]) + viewer.message.ShowModal() + viewer.message.Destroy() # Also implement "view_item" if standard keymap uses it def get_formatted_message(self): diff --git a/src/controller/buffers/blueski/timeline.py b/src/controller/buffers/blueski/timeline.py index 4c776ef3..23549982 100644 --- a/src/controller/buffers/blueski/timeline.py +++ b/src/controller/buffers/blueski/timeline.py @@ -215,11 +215,40 @@ class Conversation(BaseBuffer): traverse(thread) self.session.db[self.name] = [] self.buffer.list.clear() - return self.process_items(final_items, play_sound) + # Don't use process_items() because it applies reverse logic. + # Conversations should always be chronological (oldest first). + return self._add_items_chronological(final_items, play_sound) except Exception as e: log.error("Error fetching thread: %s", e) return 0 + def _add_items_chronological(self, items, play_sound=True): + """Add items in chronological order (oldest first) without reverse logic.""" + if not items: + return 0 + + safe = True + relative_times = self.session.settings["general"].get("relative_times", False) + show_screen_names = self.session.settings["general"].get("show_screen_names", False) + + for item in items: + self.session.db[self.name].append(item) + post = self.compose_function(item, self.session.db, self.session.settings, + relative_times=relative_times, + show_screen_names=show_screen_names, + safe=safe) + self.buffer.list.insert_item(False, *post) + + # Select the root post (first item after ancestors, or just the first) + total = self.buffer.list.get_count() + if total > 0: + self.buffer.list.select_item(0) + + if play_sound and self.sound and not self.session.settings["sound"]["session_mute"]: + self.session.sound.play(self.sound) + + return len(items) + class LikesBuffer(BaseBuffer): """User's liked posts.""" diff --git a/src/sessions/blueski/utils.py b/src/sessions/blueski/utils.py index 616d4738..5a6ee4a9 100644 --- a/src/sessions/blueski/utils.py +++ b/src/sessions/blueski/utils.py @@ -30,32 +30,86 @@ def is_audio_or_video(post): if not embed: return False - etype = g(embed, "$type") or g(embed, "py_type") + etype = g(embed, "$type") or g(embed, "py_type") or "" # Check for video embed - if etype and "video" in etype.lower(): + if "video" in etype.lower(): return True # Check for external link that might be video (YouTube, etc.) - if etype and "external" in etype: + if "external" in etype.lower(): ext = g(embed, "external", {}) uri = g(ext, "uri", "") - # Common video hosting sites video_hosts = ["youtube.com", "youtu.be", "vimeo.com", "twitch.tv", "dailymotion.com"] for host in video_hosts: if host in uri.lower(): return True # Check in recordWithMedia wrapper - if etype and "recordWithMedia" in etype: + if "recordwithmedia" in etype.lower(): media = g(embed, "media", {}) - mtype = g(media, "$type") or g(media, "py_type") - if mtype and "video" in mtype.lower(): + mtype = g(media, "$type") or g(media, "py_type") or "" + if "video" in mtype.lower(): return True + if "external" in mtype.lower(): + ext = g(media, "external", {}) + uri = g(ext, "uri", "") + video_hosts = ["youtube.com", "youtu.be", "vimeo.com", "twitch.tv", "dailymotion.com"] + for host in video_hosts: + if host in uri.lower(): + return True return False +def _extract_images_from_embed(embed): + """Extract image URLs from an embed object.""" + images = [] + if not embed: + return images + + etype = g(embed, "$type") or g(embed, "py_type") or "" + + def extract_images(img_list): + result = [] + for img in (img_list or []): + url = None + # Try all possible URL field names + for key in ["fullsize", "thumb", "url", "uri", "src"]: + val = g(img, key) + if val and isinstance(val, str) and val.startswith("http"): + url = val + break + # Also check for nested 'image' object + if not url: + image_obj = g(img, "image", {}) + if image_obj: + for key in ["ref", "$link", "url", "uri"]: + val = g(image_obj, key) + if val: + url = val + break + if url: + result.append({ + "url": url, + "alt": g(img, "alt", "") or "" + }) + return result + + # Direct images embed (app.bsky.embed.images or app.bsky.embed.images#view) + if "images" in etype.lower(): + images.extend(extract_images(g(embed, "images", []))) + + # Check in recordWithMedia wrapper + if "recordwithmedia" in etype.lower(): + media = g(embed, "media", {}) + mtype = g(media, "$type") or g(media, "py_type") or "" + if "images" in mtype.lower(): + images.extend(extract_images(g(media, "images", []))) + + return images + + def is_image(post): """ Check if post contains image content. @@ -68,25 +122,22 @@ def is_image(post): """ actual_post = g(post, "post", post) embed = g(actual_post, "embed", None) - if not embed: - return False + return len(_extract_images_from_embed(embed)) > 0 - etype = g(embed, "$type") or g(embed, "py_type") - # Direct images embed - if etype and "images" in etype: - images = g(embed, "images", []) - return len(images) > 0 +def get_image_urls(post): + """ + Get URLs for image attachments from post for OCR. - # Check in recordWithMedia wrapper - if etype and "recordWithMedia" in etype: - media = g(embed, "media", {}) - mtype = g(media, "$type") or g(media, "py_type") - if mtype and "images" in mtype: - images = g(media, "images", []) - return len(images) > 0 + Args: + post: Bluesky post object - return False + Returns: + list: List of dicts with 'url' and 'alt' keys + """ + actual_post = g(post, "post", post) + embed = g(actual_post, "embed", None) + return _extract_images_from_embed(embed) def get_media_urls(post): @@ -105,24 +156,44 @@ def get_media_urls(post): if not embed: return urls - etype = g(embed, "$type") or g(embed, "py_type") + etype = g(embed, "$type") or g(embed, "py_type") or "" - # Video embed - if etype and "video" in etype.lower(): - playlist = g(embed, "playlist", None) + def extract_video_urls(video_embed): + """Extract URLs from a video embed object.""" + result = [] + # Playlist URL (HLS stream) + playlist = g(video_embed, "playlist", None) if playlist: - urls.append(playlist) + result.append(playlist) # Alternative URL fields - for key in ["url", "uri", "thumb"]: - val = g(embed, key) - if val and val not in urls: - urls.append(val) + for key in ["url", "uri"]: + val = g(video_embed, key) + if val and val not in result: + result.append(val) + return result + + # Direct video embed (app.bsky.embed.video#view) + if "video" in etype.lower(): + urls.extend(extract_video_urls(embed)) + + # Check in recordWithMedia wrapper + if "recordWithMedia" in etype or "record_with_media" in etype.lower(): + media = g(embed, "media", {}) + mtype = g(media, "$type") or g(media, "py_type") or "" + if "video" in mtype.lower(): + urls.extend(extract_video_urls(media)) + # Also check for external in media + if "external" in mtype.lower(): + ext = g(media, "external", {}) + uri = g(ext, "uri", "") + if uri and uri not in urls: + urls.append(uri) # External links (YouTube, etc.) - if etype and "external" in etype: + if "external" in etype.lower(): ext = g(embed, "external", {}) uri = g(ext, "uri", "") - if uri: + if uri and uri not in urls: urls.append(uri) return urls diff --git a/src/wxUI/buffers/blueski/panels.py b/src/wxUI/buffers/blueski/panels.py index 97813ffc..8177ab3b 100644 --- a/src/wxUI/buffers/blueski/panels.py +++ b/src/wxUI/buffers/blueski/panels.py @@ -49,8 +49,8 @@ class HomePanel(wx.Panel): # But for now, simple list is what the previous code had. def set_focus_function(self, func): - self.list.list.Bind(wx.EVT_SET_FOCUS, func) - + self.list.list.Bind(wx.EVT_LIST_ITEM_FOCUSED, func) + def set_position(self, reverse): if reverse: self.list.select_item(0) @@ -93,8 +93,8 @@ class UserPanel(wx.Panel): self.SetSizer(self.sizer) def set_focus_function(self, func): - self.list.list.Bind(wx.EVT_SET_FOCUS, func) - + self.list.list.Bind(wx.EVT_LIST_ITEM_FOCUSED, func) + def set_position(self, reverse): if reverse: self.list.select_item(0) @@ -124,7 +124,7 @@ class ChatPanel(wx.Panel): self.SetSizer(self.sizer) def set_focus_function(self, func): - self.list.list.Bind(wx.EVT_SET_FOCUS, func) + self.list.list.Bind(wx.EVT_LIST_ITEM_FOCUSED, func) def set_focus_in_list(self): self.list.list.SetFocus() diff --git a/src/wxUI/dialogs/blueski/postDialogs.py b/src/wxUI/dialogs/blueski/postDialogs.py index 856b84cd..98ea00be 100644 --- a/src/wxUI/dialogs/blueski/postDialogs.py +++ b/src/wxUI/dialogs/blueski/postDialogs.py @@ -203,3 +203,28 @@ class viewPost(wx.Dialog): if hasattr(self, buttonName): return getattr(self, buttonName).Enable() + +class viewText(wx.Dialog): + def __init__(self, title="", text="", *args, **kwargs): + super(viewText, self).__init__(parent=None, id=wx.ID_ANY, size=(850, 850), title=title) + panel = wx.Panel(self) + label = wx.StaticText(panel, -1, _("Text")) + self.text = wx.TextCtrl(panel, -1, text, style=wx.TE_READONLY | wx.TE_MULTILINE, size=(250, 180)) + self.text.SetFocus() + textBox = wx.BoxSizer(wx.HORIZONTAL) + textBox.Add(label, 0, wx.ALL, 5) + textBox.Add(self.text, 1, wx.EXPAND, 5) + mainBox = wx.BoxSizer(wx.VERTICAL) + mainBox.Add(textBox, 0, wx.ALL, 5) + self.spellcheck = wx.Button(panel, -1, _("Check &spelling..."), size=wx.DefaultSize) + self.translateButton = wx.Button(panel, -1, _("&Translate..."), size=wx.DefaultSize) + cancelButton = wx.Button(panel, wx.ID_CANCEL, _("C&lose"), size=wx.DefaultSize) + cancelButton.SetDefault() + buttonsBox = wx.BoxSizer(wx.HORIZONTAL) + buttonsBox.Add(self.spellcheck, 0, wx.ALL, 5) + buttonsBox.Add(self.translateButton, 0, wx.ALL, 5) + buttonsBox.Add(cancelButton, 0, wx.ALL, 5) + mainBox.Add(buttonsBox, 0, wx.ALL, 5) + panel.SetSizer(mainBox) + self.SetClientSize(mainBox.CalcMin()) +