2026-02-01 21:10:46 +01:00
|
|
|
# -*- coding: utf-8 -*-
|
|
|
|
|
import arrow
|
|
|
|
|
import languageHandler
|
|
|
|
|
from string import Template
|
2026-02-15 23:50:00 +00:00
|
|
|
from sessions.blueski import utils
|
2026-02-01 21:10:46 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
post_variables = [
|
|
|
|
|
"date",
|
|
|
|
|
"display_name",
|
|
|
|
|
"screen_name",
|
2026-02-15 23:50:00 +00:00
|
|
|
"reply_to",
|
2026-02-01 21:10:46 +01:00
|
|
|
"source",
|
|
|
|
|
"lang",
|
|
|
|
|
"safe_text",
|
|
|
|
|
"text",
|
|
|
|
|
"image_descriptions",
|
|
|
|
|
"visibility",
|
|
|
|
|
"pinned",
|
|
|
|
|
]
|
|
|
|
|
person_variables = [
|
|
|
|
|
"display_name",
|
|
|
|
|
"screen_name",
|
|
|
|
|
"description",
|
|
|
|
|
"followers",
|
|
|
|
|
"following",
|
|
|
|
|
"favorites",
|
|
|
|
|
"posts",
|
|
|
|
|
"created_at",
|
|
|
|
|
]
|
|
|
|
|
notification_variables = ["display_name", "screen_name", "text", "date"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _g(obj, key, default=None):
|
|
|
|
|
if isinstance(obj, dict):
|
|
|
|
|
return obj.get(key, default)
|
|
|
|
|
return getattr(obj, key, default)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _extract_labels(obj):
|
|
|
|
|
labels = _g(obj, "labels", None)
|
|
|
|
|
if labels is None:
|
|
|
|
|
return []
|
|
|
|
|
if isinstance(labels, dict):
|
|
|
|
|
return labels.get("values", []) or []
|
|
|
|
|
if isinstance(labels, list):
|
|
|
|
|
return labels
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _extract_cw_text(post, record):
|
|
|
|
|
labels = _extract_labels(post) + _extract_labels(record)
|
|
|
|
|
for label in labels:
|
|
|
|
|
val = _g(label, "val", "")
|
|
|
|
|
if val == "warn":
|
|
|
|
|
return _("Sensitive Content")
|
|
|
|
|
if isinstance(val, str) and val.startswith("warn:"):
|
|
|
|
|
return val.split("warn:", 1)[-1].strip()
|
|
|
|
|
return ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _extract_image_descriptions(post, record):
|
|
|
|
|
def collect_images(embed):
|
|
|
|
|
if not embed:
|
|
|
|
|
return []
|
|
|
|
|
etype = _g(embed, "$type") or _g(embed, "py_type") or ""
|
|
|
|
|
if "recordWithMedia" in etype:
|
|
|
|
|
media = _g(embed, "media")
|
|
|
|
|
mtype = _g(media, "$type") or _g(media, "py_type") or ""
|
|
|
|
|
if "images" in mtype:
|
|
|
|
|
return list(_g(media, "images", []) or [])
|
2026-06-11 08:40:48 +02:00
|
|
|
if "gallery" in mtype:
|
|
|
|
|
return list(_g(media, "items", []) or [])
|
2026-02-01 21:10:46 +01:00
|
|
|
return []
|
|
|
|
|
if "images" in etype:
|
|
|
|
|
return list(_g(embed, "images", []) or [])
|
2026-06-11 08:40:48 +02:00
|
|
|
if "gallery" in etype:
|
|
|
|
|
return list(_g(embed, "items", []) or [])
|
2026-02-01 21:10:46 +01:00
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
images = []
|
|
|
|
|
images.extend(collect_images(_g(post, "embed")))
|
|
|
|
|
if not images:
|
|
|
|
|
images.extend(collect_images(_g(record, "embed")))
|
|
|
|
|
|
|
|
|
|
descriptions = []
|
|
|
|
|
for idx, img in enumerate(images, start=1):
|
|
|
|
|
alt = _g(img, "alt", "") or ""
|
|
|
|
|
if alt:
|
|
|
|
|
descriptions.append(_("Media description {index}: {alt}").format(index=idx, alt=alt))
|
|
|
|
|
return "\n".join(descriptions)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def process_date(field, relative_times=True, offset_hours=0):
|
|
|
|
|
original_date = arrow.get(field)
|
|
|
|
|
if relative_times:
|
|
|
|
|
return original_date.humanize(locale=languageHandler.curLang[:2])
|
|
|
|
|
return original_date.shift(hours=offset_hours).format(_("dddd, MMMM D, YYYY H:m:s"), locale=languageHandler.curLang[:2])
|
|
|
|
|
|
|
|
|
|
|
2026-02-02 18:41:11 +01:00
|
|
|
def _extract_link_info(post, record):
|
|
|
|
|
"""Extract link information from post embeds and facets."""
|
|
|
|
|
embed = _g(post, "embed")
|
|
|
|
|
if not embed:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
etype = _g(embed, "$type") or _g(embed, "py_type") or ""
|
|
|
|
|
|
|
|
|
|
# Direct external embed
|
|
|
|
|
if "external" in etype.lower():
|
|
|
|
|
ext = _g(embed, "external", {})
|
|
|
|
|
title = _g(ext, "title", "")
|
|
|
|
|
if title:
|
|
|
|
|
return title
|
|
|
|
|
|
|
|
|
|
# RecordWithMedia with external
|
|
|
|
|
if "recordWithMedia" in etype:
|
|
|
|
|
media = _g(embed, "media", {})
|
|
|
|
|
mtype = _g(media, "$type") or _g(media, "py_type") or ""
|
|
|
|
|
if "external" in mtype.lower():
|
|
|
|
|
ext = _g(media, "external", {})
|
|
|
|
|
title = _g(ext, "title", "")
|
|
|
|
|
if title:
|
|
|
|
|
return title
|
|
|
|
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
2026-02-01 21:10:46 +01:00
|
|
|
def render_post(post, template, settings, relative_times=False, offset_hours=0):
|
|
|
|
|
actual_post = _g(post, "post", post)
|
|
|
|
|
record = _g(actual_post, "record") or _g(post, "record") or {}
|
|
|
|
|
author = _g(actual_post, "author") or _g(post, "author") or {}
|
|
|
|
|
|
|
|
|
|
reason = _g(post, "reason")
|
|
|
|
|
is_repost = False
|
|
|
|
|
reposter = None
|
|
|
|
|
if reason:
|
|
|
|
|
rtype = _g(reason, "$type") or _g(reason, "py_type") or ""
|
|
|
|
|
if "reasonRepost" in rtype:
|
|
|
|
|
is_repost = True
|
|
|
|
|
reposter = _g(reason, "by")
|
|
|
|
|
|
|
|
|
|
if is_repost and reposter:
|
|
|
|
|
display_name = _g(reposter, "displayName") or _g(reposter, "display_name") or _g(reposter, "handle", "")
|
|
|
|
|
screen_name = _g(reposter, "handle", "")
|
|
|
|
|
else:
|
|
|
|
|
display_name = _g(author, "displayName") or _g(author, "display_name") or _g(author, "handle", "")
|
|
|
|
|
screen_name = _g(author, "handle", "")
|
|
|
|
|
|
|
|
|
|
text = _g(record, "text", "") or ""
|
|
|
|
|
if is_repost:
|
|
|
|
|
original_handle = _g(author, "handle", "")
|
|
|
|
|
text = _("Reposted from @{handle}: {text}").format(handle=original_handle, text=text)
|
|
|
|
|
|
2026-02-15 23:50:00 +00:00
|
|
|
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)
|
|
|
|
|
|
2026-02-02 18:41:11 +01:00
|
|
|
# Add link indicator for external embeds
|
|
|
|
|
link_title = _extract_link_info(actual_post, record)
|
|
|
|
|
if link_title:
|
|
|
|
|
text += f" [{_('Link')}: {link_title}]"
|
|
|
|
|
|
2026-02-15 23:50:00 +00:00
|
|
|
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)
|
|
|
|
|
|
2026-02-01 21:10:46 +01:00
|
|
|
cw_text = _extract_cw_text(actual_post, record)
|
|
|
|
|
safe_text = text
|
|
|
|
|
if cw_text:
|
2026-02-02 18:41:11 +01:00
|
|
|
# Include link info in safe_text even with content warning
|
|
|
|
|
if link_title:
|
|
|
|
|
safe_text = _("Content warning: {cw}").format(cw=cw_text) + f" [{_('Link')}: {link_title}]"
|
|
|
|
|
else:
|
|
|
|
|
safe_text = _("Content warning: {cw}").format(cw=cw_text)
|
2026-02-01 21:10:46 +01:00
|
|
|
|
2026-02-15 23:50:00 +00:00
|
|
|
# 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 = ""
|
|
|
|
|
|
2026-02-01 21:10:46 +01:00
|
|
|
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
|
|
|
|
|
date = process_date(date_field, relative_times, offset_hours) if date_field else ""
|
|
|
|
|
|
|
|
|
|
langs = _g(record, "langs") or _g(record, "languages") or []
|
|
|
|
|
lang = langs[0] if isinstance(langs, list) and langs else ""
|
|
|
|
|
|
|
|
|
|
image_descriptions = _extract_image_descriptions(actual_post, record)
|
|
|
|
|
|
|
|
|
|
available_data = dict(
|
|
|
|
|
date=date,
|
|
|
|
|
display_name=display_name,
|
|
|
|
|
screen_name=screen_name,
|
2026-02-15 23:50:00 +00:00
|
|
|
reply_to=reply_to,
|
2026-02-01 21:10:46 +01:00
|
|
|
source="Bluesky",
|
|
|
|
|
lang=lang,
|
|
|
|
|
safe_text=safe_text,
|
|
|
|
|
text=text,
|
|
|
|
|
image_descriptions=image_descriptions,
|
|
|
|
|
visibility=_("Public"),
|
|
|
|
|
pinned="",
|
|
|
|
|
)
|
|
|
|
|
return Template(_(template)).safe_substitute(**available_data)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def render_user(user, template, settings, relative_times=True, offset_hours=0):
|
2026-02-02 18:54:18 +01:00
|
|
|
# Resolve nested profile structure (subject, actor, profile, user)
|
|
|
|
|
def resolve_profile(obj):
|
|
|
|
|
if _g(obj, "handle") or _g(obj, "did"):
|
|
|
|
|
return obj
|
|
|
|
|
for key in ("subject", "actor", "profile", "user"):
|
|
|
|
|
nested = _g(obj, key)
|
|
|
|
|
if nested and (_g(nested, "handle") or _g(nested, "did")):
|
|
|
|
|
return nested
|
|
|
|
|
return obj
|
|
|
|
|
|
|
|
|
|
profile = resolve_profile(user)
|
|
|
|
|
display_name = _g(profile, "displayName") or _g(profile, "display_name") or _g(profile, "handle", "")
|
|
|
|
|
screen_name = _g(profile, "handle", "")
|
|
|
|
|
description = _g(profile, "description", "") or ""
|
|
|
|
|
followers = _g(profile, "followersCount") or _g(profile, "followers_count") or 0
|
|
|
|
|
following = _g(profile, "followsCount") or _g(profile, "follows_count") or 0
|
|
|
|
|
posts = _g(profile, "postsCount") or _g(profile, "posts_count") or 0
|
|
|
|
|
created_at = _g(profile, "createdAt") or _g(profile, "created_at")
|
2026-02-01 21:10:46 +01:00
|
|
|
created = ""
|
|
|
|
|
if created_at:
|
|
|
|
|
created = process_date(created_at, relative_times, offset_hours)
|
|
|
|
|
|
|
|
|
|
available_data = dict(
|
|
|
|
|
display_name=display_name,
|
|
|
|
|
screen_name=screen_name,
|
|
|
|
|
description=description,
|
|
|
|
|
followers=followers,
|
|
|
|
|
following=following,
|
|
|
|
|
favorites="",
|
|
|
|
|
posts=posts,
|
|
|
|
|
created_at=created,
|
|
|
|
|
)
|
|
|
|
|
return Template(_(template)).safe_substitute(**available_data)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def render_notification(notification, template, post_template, settings, relative_times=False, offset_hours=0):
|
|
|
|
|
author = _g(notification, "author") or {}
|
|
|
|
|
display_name = _g(author, "displayName") or _g(author, "display_name") or _g(author, "handle", "")
|
|
|
|
|
screen_name = _g(author, "handle", "")
|
|
|
|
|
reason = _g(notification, "reason", "unknown")
|
|
|
|
|
record = _g(notification, "record") or {}
|
2026-02-03 13:28:12 +01:00
|
|
|
|
|
|
|
|
# Get post text - try multiple locations depending on notification type
|
2026-02-01 21:10:46 +01:00
|
|
|
post_text = _g(record, "text", "") or ""
|
|
|
|
|
|
2026-02-03 13:28:12 +01:00
|
|
|
# For likes and reposts: try to get the subject post text
|
|
|
|
|
if not post_text and reason in ("like", "repost"):
|
|
|
|
|
# First check for hydrated subject text (added by NotificationBuffer)
|
|
|
|
|
post_text = _g(notification, "_subject_text", "") or ""
|
|
|
|
|
|
|
|
|
|
# Check if there's a reasonSubject with embedded post data
|
|
|
|
|
if not post_text:
|
|
|
|
|
reason_subject = _g(notification, "reasonSubject") or _g(notification, "reason_subject")
|
|
|
|
|
if reason_subject:
|
|
|
|
|
subject_record = _g(reason_subject, "record", {})
|
|
|
|
|
post_text = _g(subject_record, "text", "") or ""
|
|
|
|
|
|
|
|
|
|
# Check subject in record
|
|
|
|
|
if not post_text:
|
|
|
|
|
subject = _g(record, "subject", {})
|
|
|
|
|
post_text = _g(subject, "text", "") or ""
|
|
|
|
|
|
|
|
|
|
# Format: action text without username (username is already in display_name for template)
|
2026-02-01 21:10:46 +01:00
|
|
|
if reason == "like":
|
2026-02-03 13:28:12 +01:00
|
|
|
text = _("has added to favorites: {status}").format(status=post_text) if post_text else _("has added to favorites")
|
2026-02-01 21:10:46 +01:00
|
|
|
elif reason == "repost":
|
2026-02-03 13:28:12 +01:00
|
|
|
text = _("has reposted: {status}").format(status=post_text) if post_text else _("has reposted")
|
2026-02-01 21:10:46 +01:00
|
|
|
elif reason == "follow":
|
2026-02-03 13:28:12 +01:00
|
|
|
text = _("has followed you.")
|
2026-02-01 21:10:46 +01:00
|
|
|
elif reason == "mention":
|
2026-02-03 13:28:12 +01:00
|
|
|
text = _("has mentioned you: {status}").format(status=post_text) if post_text else _("has mentioned you")
|
2026-02-01 21:10:46 +01:00
|
|
|
elif reason == "reply":
|
2026-02-03 13:28:12 +01:00
|
|
|
text = _("has replied: {status}").format(status=post_text) if post_text else _("has replied")
|
2026-02-01 21:10:46 +01:00
|
|
|
elif reason == "quote":
|
2026-02-03 13:28:12 +01:00
|
|
|
text = _("has quoted your post: {status}").format(status=post_text) if post_text else _("has quoted your post")
|
2026-02-01 21:10:46 +01:00
|
|
|
else:
|
2026-02-03 13:28:12 +01:00
|
|
|
text = reason
|
2026-02-01 21:10:46 +01:00
|
|
|
|
|
|
|
|
indexed_at = _g(notification, "indexedAt") or _g(notification, "indexed_at")
|
|
|
|
|
date = process_date(indexed_at, relative_times, offset_hours) if indexed_at else ""
|
|
|
|
|
|
|
|
|
|
available_data = dict(
|
|
|
|
|
display_name=display_name,
|
|
|
|
|
screen_name=screen_name,
|
|
|
|
|
text=text,
|
|
|
|
|
date=date,
|
|
|
|
|
)
|
|
|
|
|
return Template(_(template)).safe_substitute(**available_data)
|