mirror of
https://github.com/MCV-Software/TWBlue.git
synced 2026-03-06 09:27:33 +01:00
Improve Bluesky reply/quote accessibility and split test bootstrap workflow
- Fix Bluesky quoted post rendering across list output, screen-reader speech, and View Post by centralizing quote extraction. - Add robust quote URL extraction (facets/embed/text), include quoted URLs in URL shortcuts, and append full quoted URLs when hidden/truncated. - Improve reply context handling: - add and use `$reply_to` template variable, - hydrate missing reply target handles in home/feed items, - keep backward compatibility for templates that do not include `$reply_to`. - Align Bluesky default/fallback post templates to include reply context (`$reply_to`). - Add/extend focused Bluesky tests for quote text, quote URLs, reply context, and template fallback behavior. - Refactor scripts: - add bootstrap-dev.ps1 for environment setup (submodules, venv, deps), - keep run-tests.ps1 focused on running tests only, - add PowerShell comment-based help in English. - Update README with the new bootstrap/test workflow and examples.
This commit is contained in:
@@ -11,6 +11,7 @@ list controls. They follow the TWBlue compose function pattern:
|
||||
import logging
|
||||
import arrow
|
||||
import languageHandler
|
||||
from sessions.blueski import utils
|
||||
|
||||
log = logging.getLogger("sessions.blueski.compose")
|
||||
|
||||
@@ -76,6 +77,13 @@ def compose_post(post, db, settings, relative_times, show_screen_names=False, sa
|
||||
else:
|
||||
text = original_text
|
||||
|
||||
reply_to_handle = utils.extract_reply_to_handle(post)
|
||||
if reply_to_handle:
|
||||
if text:
|
||||
text = _("Replying to @{}: {}").format(reply_to_handle, text)
|
||||
else:
|
||||
text = _("Replying to @{}").format(reply_to_handle)
|
||||
|
||||
# Check facets for links not visible in text and append them
|
||||
facets = g(record, "facets", []) or []
|
||||
hidden_urls = []
|
||||
@@ -117,7 +125,7 @@ def compose_post(post, db, settings, relative_times, show_screen_names=False, sa
|
||||
if cw_text:
|
||||
text = f"CW: {cw_text}\n\n{text}"
|
||||
|
||||
# Embeds (Images, Quotes, Links)
|
||||
# Embeds (Images, Links)
|
||||
embed = g(actual_post, "embed", None)
|
||||
if embed:
|
||||
etype = g(embed, "$type") or g(embed, "py_type")
|
||||
@@ -128,12 +136,7 @@ def compose_post(post, db, settings, relative_times, show_screen_names=False, sa
|
||||
if images:
|
||||
text += f" [{len(images)} {_('images')}]"
|
||||
|
||||
# Quote posts
|
||||
quote_rec = None
|
||||
if etype and ("recordWithMedia" in etype):
|
||||
rec_embed = g(embed, "record", {})
|
||||
if rec_embed:
|
||||
quote_rec = g(rec_embed, "record", None) or rec_embed
|
||||
media = g(embed, "media", {})
|
||||
mtype = g(media, "$type") or g(media, "py_type")
|
||||
if mtype and "images" in mtype:
|
||||
@@ -145,37 +148,33 @@ def compose_post(post, db, settings, relative_times, show_screen_names=False, sa
|
||||
title = g(ext, "title", "")
|
||||
if title:
|
||||
text += f" [{_('Link')}: {title}]"
|
||||
|
||||
elif etype and ("record" in etype):
|
||||
quote_rec = g(embed, "record", {})
|
||||
if isinstance(quote_rec, dict):
|
||||
quote_rec = quote_rec.get("record") or quote_rec
|
||||
|
||||
if quote_rec:
|
||||
qtype = g(quote_rec, "$type") or g(quote_rec, "py_type")
|
||||
if qtype and "viewNotFound" in qtype:
|
||||
text += f" [{_('Quoted post not found')}]"
|
||||
elif qtype and "viewBlocked" in qtype:
|
||||
text += f" [{_('Quoted post blocked')}]"
|
||||
elif qtype and "generatorView" in qtype:
|
||||
gen = g(quote_rec, "displayName", "Feed")
|
||||
text += f" [{_('Quoting Feed')}: {gen}]"
|
||||
else:
|
||||
q_author = g(quote_rec, "author", {})
|
||||
q_handle = g(q_author, "handle", "unknown")
|
||||
q_val = g(quote_rec, "value", {})
|
||||
q_text = g(q_val, "text", "")
|
||||
if q_text:
|
||||
text += " " + _("Quoting @{}: {}").format(q_handle, q_text)
|
||||
else:
|
||||
text += " " + _("Quoting @{}").format(q_handle)
|
||||
|
||||
elif etype and ("external" in etype):
|
||||
ext = g(embed, "external", {})
|
||||
title = g(ext, "title", "")
|
||||
if title:
|
||||
text += f" [{_('Link')}: {title}]"
|
||||
|
||||
quote_info = utils.extract_quoted_post_info(post)
|
||||
if quote_info:
|
||||
if quote_info["kind"] == "not_found":
|
||||
text += f" [{_('Quoted post not found')}]"
|
||||
elif quote_info["kind"] == "blocked":
|
||||
text += f" [{_('Quoted post blocked')}]"
|
||||
elif quote_info["kind"] == "feed":
|
||||
text += f" [{_('Quoting Feed')}: {quote_info.get('feed_name', 'Feed')}]"
|
||||
else:
|
||||
q_handle = quote_info.get("handle", "unknown")
|
||||
q_text = quote_info.get("text", "")
|
||||
if q_text:
|
||||
text += " " + _("Quoting @{}: {}").format(q_handle, q_text)
|
||||
else:
|
||||
text += " " + _("Quoting @{}").format(q_handle)
|
||||
|
||||
# Add full URLs from quoted content when they are not visible in text.
|
||||
for uri in quote_info.get("urls", []):
|
||||
if uri and uri not in text:
|
||||
text += f" [{uri}]"
|
||||
|
||||
# Date
|
||||
indexed_at = g(actual_post, "indexed_at", "") or g(actual_post, "indexedAt", "")
|
||||
ts_str = ""
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
import arrow
|
||||
import languageHandler
|
||||
from string import Template
|
||||
from sessions.blueski import utils
|
||||
|
||||
|
||||
post_variables = [
|
||||
"date",
|
||||
"display_name",
|
||||
"screen_name",
|
||||
"reply_to",
|
||||
"source",
|
||||
"lang",
|
||||
"safe_text",
|
||||
@@ -146,11 +148,32 @@ def render_post(post, template, settings, relative_times=False, offset_hours=0):
|
||||
original_handle = _g(author, "handle", "")
|
||||
text = _("Reposted from @{handle}: {text}").format(handle=original_handle, text=text)
|
||||
|
||||
quote_info = utils.extract_quoted_post_info(post)
|
||||
if quote_info:
|
||||
if quote_info["kind"] == "not_found":
|
||||
text += f" [{_('Quoted post not found')}]"
|
||||
elif quote_info["kind"] == "blocked":
|
||||
text += f" [{_('Quoted post blocked')}]"
|
||||
elif quote_info["kind"] == "feed":
|
||||
text += f" [{_('Quoting Feed')}: {quote_info.get('feed_name', 'Feed')}]"
|
||||
else:
|
||||
q_handle = quote_info.get("handle", "unknown")
|
||||
q_text = quote_info.get("text", "")
|
||||
if q_text:
|
||||
text += " " + _("Quoting @{handle}: {text}").format(handle=q_handle, text=q_text)
|
||||
else:
|
||||
text += " " + _("Quoting @{handle}").format(handle=q_handle)
|
||||
|
||||
# Add link indicator for external embeds
|
||||
link_title = _extract_link_info(actual_post, record)
|
||||
if link_title:
|
||||
text += f" [{_('Link')}: {link_title}]"
|
||||
|
||||
reply_to_handle = utils.extract_reply_to_handle(post)
|
||||
reply_to = ""
|
||||
if reply_to_handle:
|
||||
reply_to = _("Replying to @{handle}. ").format(handle=reply_to_handle)
|
||||
|
||||
cw_text = _extract_cw_text(actual_post, record)
|
||||
safe_text = text
|
||||
if cw_text:
|
||||
@@ -160,6 +183,13 @@ def render_post(post, template, settings, relative_times=False, offset_hours=0):
|
||||
else:
|
||||
safe_text = _("Content warning: {cw}").format(cw=cw_text)
|
||||
|
||||
# Backward compatibility: older user templates may not include $reply_to.
|
||||
# In that case, prepend the reply marker directly so users still get context.
|
||||
if reply_to and "$reply_to" not in template:
|
||||
text = reply_to + text
|
||||
safe_text = reply_to + safe_text
|
||||
reply_to = ""
|
||||
|
||||
created_at = _g(record, "createdAt") or _g(record, "created_at")
|
||||
indexed_at = _g(actual_post, "indexedAt") or _g(actual_post, "indexed_at")
|
||||
date_field = created_at or indexed_at
|
||||
@@ -174,6 +204,7 @@ def render_post(post, template, settings, relative_times=False, offset_hours=0):
|
||||
date=date,
|
||||
display_name=display_name,
|
||||
screen_name=screen_name,
|
||||
reply_to=reply_to,
|
||||
source="Bluesky",
|
||||
lang=lang,
|
||||
safe_text=safe_text,
|
||||
|
||||
@@ -265,6 +265,13 @@ def find_urls(post):
|
||||
if u not in urls:
|
||||
urls.append(u)
|
||||
|
||||
# Include URLs from quoted post, if present.
|
||||
quote_info = extract_quoted_post_info(post)
|
||||
if quote_info and quote_info.get("kind") == "post":
|
||||
for uri in quote_info.get("urls", []):
|
||||
if uri and uri not in urls:
|
||||
urls.append(uri)
|
||||
|
||||
return urls
|
||||
|
||||
|
||||
@@ -289,3 +296,149 @@ def find_item(item, items_list):
|
||||
return i
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_quoted_record_from_embed(embed):
|
||||
"""Resolve quoted record payload from a Bluesky embed structure."""
|
||||
if not embed:
|
||||
return None
|
||||
|
||||
etype = (g(embed, "$type") or g(embed, "py_type") or "").lower()
|
||||
|
||||
candidate = None
|
||||
if "recordwithmedia" in etype:
|
||||
record_view = g(embed, "record")
|
||||
candidate = g(record_view, "record") or record_view
|
||||
elif "record" in etype:
|
||||
candidate = g(embed, "record") or embed
|
||||
else:
|
||||
record_view = g(embed, "record")
|
||||
if record_view is not None:
|
||||
candidate = g(record_view, "record") or record_view
|
||||
|
||||
if not candidate:
|
||||
return None
|
||||
|
||||
# Unwrap one extra layer if still wrapped in a record-view container.
|
||||
nested = g(candidate, "record")
|
||||
nested_type = (g(nested, "$type") or g(nested, "py_type") or "").lower() if nested else ""
|
||||
if nested and ("view" in nested_type or "record" in nested_type):
|
||||
return nested
|
||||
|
||||
return candidate
|
||||
|
||||
|
||||
def extract_reply_to_handle(post):
|
||||
"""
|
||||
Best-effort extraction of the replied-to handle for a Bluesky post.
|
||||
|
||||
Returns:
|
||||
str | None: Handle (without @) when available.
|
||||
"""
|
||||
actual_post = g(post, "post", post)
|
||||
|
||||
# Fast path: pre-hydrated by buffers/session.
|
||||
cached = g(post, "_reply_to_handle", None) or g(actual_post, "_reply_to_handle", None)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
# Feed views frequently include hydrated reply context.
|
||||
reply_view = g(post, "reply", None) or g(actual_post, "reply", None)
|
||||
if reply_view:
|
||||
parent = g(reply_view, "parent", None) or g(reply_view, "post", None) or reply_view
|
||||
parent_post = g(parent, "post", None) or parent
|
||||
parent_author = g(parent_post, "author", None) or g(parent, "author", None)
|
||||
handle = g(parent_author, "handle", None)
|
||||
if handle:
|
||||
return handle
|
||||
|
||||
# Some payloads include parent author directly under record.reply.parent.
|
||||
record = g(actual_post, "record", {}) or {}
|
||||
record_reply = g(record, "reply", None)
|
||||
if record_reply:
|
||||
parent = g(record_reply, "parent", None) or record_reply
|
||||
parent_post = g(parent, "post", None) or parent
|
||||
parent_author = g(parent_post, "author", None) or g(parent, "author", None)
|
||||
handle = g(parent_author, "handle", None)
|
||||
if handle:
|
||||
return handle
|
||||
|
||||
# When only record.reply is available, we generally only have strong refs.
|
||||
# No handle can be resolved here without extra API calls.
|
||||
return None
|
||||
|
||||
|
||||
def extract_quoted_post_info(post):
|
||||
"""
|
||||
Extract quoted content metadata from a Bluesky post.
|
||||
|
||||
Returns:
|
||||
dict | None: one of:
|
||||
- {"kind": "not_found"}
|
||||
- {"kind": "blocked"}
|
||||
- {"kind": "feed", "feed_name": "..."}
|
||||
- {"kind": "post", "handle": "...", "text": "...", "urls": ["..."]}
|
||||
"""
|
||||
actual_post = g(post, "post", post)
|
||||
record = g(actual_post, "record", {}) or {}
|
||||
embed = g(actual_post, "embed", None) or g(record, "embed", None)
|
||||
quote_rec = _resolve_quoted_record_from_embed(embed)
|
||||
if not quote_rec:
|
||||
return None
|
||||
|
||||
qtype = (g(quote_rec, "$type") or g(quote_rec, "py_type") or "").lower()
|
||||
if "viewnotfound" in qtype:
|
||||
return {"kind": "not_found"}
|
||||
if "viewblocked" in qtype:
|
||||
return {"kind": "blocked"}
|
||||
if "generatorview" in qtype:
|
||||
return {"kind": "feed", "feed_name": g(quote_rec, "displayName", "Feed")}
|
||||
|
||||
q_author = g(quote_rec, "author", {}) or {}
|
||||
q_handle = g(q_author, "handle", "unknown") or "unknown"
|
||||
|
||||
q_value = g(quote_rec, "value") or g(quote_rec, "record") or {}
|
||||
q_text = g(q_value, "text", "") or g(quote_rec, "text", "")
|
||||
if not q_text:
|
||||
nested_value = g(q_value, "value") or {}
|
||||
q_text = g(nested_value, "text", "")
|
||||
|
||||
q_urls = []
|
||||
|
||||
q_facets = g(q_value, "facets", []) or []
|
||||
for facet in q_facets:
|
||||
features = g(facet, "features", []) or []
|
||||
for feature in features:
|
||||
ftype = (g(feature, "$type") or g(feature, "py_type") or "").lower()
|
||||
if "link" in ftype:
|
||||
uri = g(feature, "uri", "")
|
||||
if uri and uri not in q_urls:
|
||||
q_urls.append(uri)
|
||||
|
||||
q_embed = g(quote_rec, "embed", None) or g(q_value, "embed", None)
|
||||
if q_embed:
|
||||
q_etype = (g(q_embed, "$type") or g(q_embed, "py_type") or "").lower()
|
||||
if "external" in q_etype:
|
||||
ext = g(q_embed, "external", {})
|
||||
uri = g(ext, "uri", "")
|
||||
if uri and uri not in q_urls:
|
||||
q_urls.append(uri)
|
||||
if "recordwithmedia" in q_etype:
|
||||
media = g(q_embed, "media", {})
|
||||
mtype = (g(media, "$type") or g(media, "py_type") or "").lower()
|
||||
if "external" in mtype:
|
||||
ext = g(media, "external", {})
|
||||
uri = g(ext, "uri", "")
|
||||
if uri and uri not in q_urls:
|
||||
q_urls.append(uri)
|
||||
|
||||
for uri in url_re.findall(q_text or ""):
|
||||
if uri not in q_urls:
|
||||
q_urls.append(uri)
|
||||
|
||||
return {
|
||||
"kind": "post",
|
||||
"handle": q_handle,
|
||||
"text": q_text or "",
|
||||
"urls": q_urls,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user