From b727397cd1fb220728e765f0dafc6ee258b6ed96 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 5 Jul 2024 16:26:26 -0400 Subject: [PATCH] bluesky post links and embeds --- catalog/common/models.py | 4 ++ journal/models/comment.py | 16 ++--- journal/models/common.py | 27 +++++++-- journal/models/mark.py | 4 +- journal/models/renderers.py | 8 +-- journal/models/review.py | 16 +++-- journal/models/shelf.py | 24 ++++---- mastodon/models/__init__.py | 1 - mastodon/models/bluesky.py | 50 +++++++++++++--- mastodon/models/mastodon.py | 83 +++++++++----------------- mastodon/models/threads.py | 23 +++++-- pyproject.toml | 2 +- requirements-dev.lock | 2 +- requirements.lock | 2 +- takahe/utils.py | 10 ---- users/templates/users/preferences.html | 4 +- 16 files changed, 152 insertions(+), 124 deletions(-) diff --git a/catalog/common/models.py b/catalog/common/models.py index cfd62969..df6bbc82 100644 --- a/catalog/common/models.py +++ b/catalog/common/models.py @@ -498,6 +498,10 @@ class Item(PolymorphicModel): def display_title(self) -> str: return self.title + @property + def display_description(self): + return self.brief[:155] + @classmethod def get_by_url(cls, url_or_b62: str, resolve_merge=False) -> "Self | None": b62 = url_or_b62.strip().split("/")[-1] diff --git a/journal/models/comment.py b/journal/models/comment.py index 38d8f818..23164bd8 100644 --- a/journal/models/comment.py +++ b/journal/models/comment.py @@ -12,12 +12,7 @@ from users.models import APIdentity from .common import Content from .rating import Rating -from .renderers import ( - render_post_with_macro, - render_rating, - render_spoiler_text, - render_text, -) +from .renderers import render_post_with_macro, render_spoiler_text, render_text from .shelf import ShelfManager, ShelfType @@ -125,15 +120,14 @@ class Comment(Content): def to_crosspost_params(self): spoiler_text, txt = render_spoiler_text(self.text, self.item) - content = ( - self.get_crosspost_template().format(item=self.item.display_title) - + f"\n{txt}\n{settings.SITE_INFO['site_url']}{self.item_url}" - + self.get_crosspost_postfix() - ) + txt = "\n" + txt if txt else "" + action = self.get_crosspost_template().format(item="##obj##") + content = f"{action}\n##obj_link_if_plain##{txt}{self.get_crosspost_postfix()}" params = { "content": content, "spoiler_text": spoiler_text, "sensitive": bool(spoiler_text), + "obj": self.item, } return params diff --git a/journal/models/common.py b/journal/models/common.py index 42823a85..36b119e9 100644 --- a/journal/models/common.py +++ b/journal/models/common.py @@ -3,7 +3,6 @@ import uuid from abc import abstractmethod from datetime import datetime from functools import cached_property -from operator import pos from typing import TYPE_CHECKING, Any, Self import django_rq @@ -123,11 +122,11 @@ class Piece(PolymorphicModel, UserOwnedObjectMixin): @property def url(self): - return f"/{self.url_path}/{self.uuid}" if self.url_path else None + return f"/{self.url_path}/{self.uuid}" @property def absolute_url(self): - return (settings.SITE_INFO["site_url"] + self.url) if self.url_path else None + return settings.SITE_INFO["site_url"] + self.url @property def api_url(self): @@ -219,10 +218,18 @@ class Piece(PolymorphicModel, UserOwnedObjectMixin): @abstractmethod def to_post_params(self) -> dict[str, Any]: + """ + returns a dict of parameter to create a post + """ return {} @abstractmethod def to_crosspost_params(self) -> dict[str, Any]: + """ + returns a dict of parameter to create a post for each platform + "content" - required, may contain ##obj## / ##obj_link_if_plain## / ##rating## + ... + """ return {} @classmethod @@ -343,7 +350,8 @@ class Piece(PolymorphicModel, UserOwnedObjectMixin): except Exception as e: logger.warning(f"Delete {bluesky} post {post_id} error {e}") r = bluesky.post(**params) - self.metadata.update({"bluesky_" + k: v for k, v in r.items()}) + if r: + self.metadata.update({"bluesky_" + k: v for k, v in r.items()}) return True def sync_to_threads(self, params, update_mode): @@ -359,7 +367,8 @@ class Piece(PolymorphicModel, UserOwnedObjectMixin): logger.warning(f"{self} post to {threads} failed") messages.error(threads.user, _("A recent post was not posted to Threads.")) return False - self.metadata.update({"threads_" + k: v for k, v in r.items()}) + if r: + self.metadata.update({"threads_" + k: v for k, v in r.items()}) return True def sync_to_mastodon(self, params, update_mode): @@ -495,6 +504,14 @@ class Content(Piece): def __str__(self): return f"{self.__class__.__name__}:{self.uuid}@{self.item}" + @property + def display_title(self) -> str: + raise NotImplementedError("subclass should override this") + + @property + def display_description(self) -> str: + raise NotImplementedError("subclass should override this") + class Meta: abstract = True diff --git a/journal/models/mark.py b/journal/models/mark.py index 0b0729fe..17eb7722 100644 --- a/journal/models/mark.py +++ b/journal/models/mark.py @@ -4,12 +4,10 @@ from typing import Any from django.utils import timezone from django.utils.translation import gettext_lazy as _ -from loguru import logger from catalog.models import Item -from mastodon.models import get_spoiler_text from takahe.utils import Takahe -from users.models import APIdentity, User +from users.models import APIdentity from .comment import Comment from .note import Note diff --git a/journal/models/renderers.py b/journal/models/renderers.py index 60963073..6b300b8d 100644 --- a/journal/models/renderers.py +++ b/journal/models/renderers.py @@ -73,7 +73,7 @@ def render_rating(score: int | None, star_mode=0) -> str: solid_stars = score // 2 half_star = int(bool(score % 2)) empty_stars = 5 - solid_stars if not half_star else 5 - solid_stars - 1 - if star_mode == 1: + if star_mode == 0: emoji_code = "🌕" * solid_stars + "🌗" * half_star + "🌑" * empty_stars else: emoji_code = ( @@ -87,12 +87,10 @@ def render_rating(score: int | None, star_mode=0) -> str: def render_spoiler_text(text, item): - if not text: - return None, "" - if text.find(">!") != -1: + if text and text.find(">!") != -1: spoiler_text = _( "regarding {item_title}, may contain spoiler or triggering content" ).format(item_title=item.display_title) return spoiler_text, text.replace(">!", "").replace("!<", "") else: - return None, text + return None, text or "" diff --git a/journal/models/review.py b/journal/models/review.py index a12e4171..cdaf496f 100644 --- a/journal/models/review.py +++ b/journal/models/review.py @@ -27,6 +27,14 @@ class Review(Content): title = models.CharField(max_length=500, blank=False, null=False) body = MarkdownxField() + @property + def display_title(self): + return self.title + + @property + def display_description(self): + return self.plain_content[:155] + @property def html_content(self): return render_md(self.body) @@ -89,10 +97,10 @@ class Review(Content): def to_crosspost_params(self): content = ( self.get_crosspost_template().format(item=self.item.display_title) - + f"\n{self.title}\n{self.absolute_url} " + + " ##rating##\n##obj##\n##obj_link_if_plain##" + self.get_crosspost_postfix() ) - params = {"content": content} + params = {"content": content, "obj": self, "rating": self.rating_grade} return params def to_post_params(self): @@ -103,9 +111,7 @@ class Review(Content): ) + f'
{self.title}' ) - content = ( - f"{render_rating(self.rating_grade, 1)}\n{self.get_crosspost_postfix()}" - ) + content = f"{render_rating(self.rating_grade)}\n{self.get_crosspost_postfix()}" return { "prepend_content": prepend_content, "content": content, diff --git a/journal/models/shelf.py b/journal/models/shelf.py index c7d56311..35c2b43b 100644 --- a/journal/models/shelf.py +++ b/journal/models/shelf.py @@ -14,7 +14,7 @@ from users.models import APIdentity from .common import q_item_in_category from .itemlist import List, ListMember -from .renderers import render_post_with_macro, render_rating +from .renderers import render_post_with_macro, render_rating, render_spoiler_text if TYPE_CHECKING: from .comment import Comment @@ -368,16 +368,20 @@ class ShelfMember(ListMember): return _(ShelfManager.get_action_template(self.shelf_type, self.item.category)) def to_crosspost_params(self): - action = self.get_crosspost_template().format(item=self.item.display_title) + action = self.get_crosspost_template().format(item="##obj##") if self.sibling_comment: - spoiler, txt = Takahe.get_spoiler_text(self.sibling_comment.text, self.item) + spoiler, txt = render_spoiler_text(self.sibling_comment.text, self.item) else: spoiler, txt = None, "" - stars = ( - render_rating(self.sibling_rating.grade, 1) if self.sibling_rating else "" - ) - content = f"{action} {stars} \n{self.item.absolute_url}\n{txt}\n{self.get_crosspost_postfix()}" - params = {"content": content, "spoiler_text": spoiler} + rating = self.sibling_rating.grade if self.sibling_rating else "" + txt = "\n" + txt if txt else "" + content = f"{action} ##rating## \n##obj_link_if_plain##{txt}{self.get_crosspost_postfix()}" + params = { + "content": content, + "spoiler_text": spoiler, + "obj": self.item, + "rating": rating, + } return params def to_post_params(self): @@ -386,14 +390,14 @@ class ShelfMember(ListMember): item=f'{self.item.display_title}' ) if self.sibling_comment: - spoiler, txt = Takahe.get_spoiler_text(self.sibling_comment.text, self.item) + spoiler, txt = render_spoiler_text(self.sibling_comment.text, self.item) else: spoiler, txt = None, "" postfix = self.get_crosspost_postfix() # add @user.mastodon.handle so that user can see it on Mastodon ? # if self.visibility and self.owner.user.mastodon: # postfix += f" @{self.owner.user.mastodon.handle}" - content = f"{render_rating(self.sibling_rating.grade, 1) if self.sibling_rating else ''} \n{txt}\n{postfix}" + content = f"{render_rating(self.sibling_rating.grade) if self.sibling_rating else ''} \n{txt}\n{postfix}" return { "prepend_content": action, "content": content, diff --git a/mastodon/models/__init__.py b/mastodon/models/__init__.py index caef9c75..ac57db48 100644 --- a/mastodon/models/__init__.py +++ b/mastodon/models/__init__.py @@ -6,7 +6,6 @@ from .mastodon import ( MastodonAccount, MastodonApplication, detect_server_info, - get_spoiler_text, verify_client, ) from .threads import Threads, ThreadsAccount diff --git a/mastodon/models/bluesky.py b/mastodon/models/bluesky.py index cb139a89..6073ca49 100644 --- a/mastodon/models/bluesky.py +++ b/mastodon/models/bluesky.py @@ -1,12 +1,11 @@ import re +import typing from functools import cached_property -from operator import pos from atproto import Client, SessionEvent, client_utils from atproto_client import models from atproto_identity.did.resolver import DidResolver from atproto_identity.handle.resolver import HandleResolver -from django.db.models import base from django.utils import timezone from loguru import logger @@ -14,11 +13,15 @@ from catalog.common import jsondata from .common import SocialAccount +if typing.TYPE_CHECKING: + from catalog.common.models import Item + from journal.models.common import Content + class Bluesky: _DOMAIN = "-" _RE_HANDLE = re.compile( - r"/^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/" + r"^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$" ) # for BlueskyAccount # uid is did and the only unique identifier @@ -103,7 +106,7 @@ class BlueskyAccount(SocialAccount): @property def url(self): - return f"{self.base_url}/profile/{self.handle}" + return f"https://{self.handle}" def refresh(self, save=True, did_refresh=True): if did_refresh: @@ -159,7 +162,16 @@ class BlueskyAccount(SocialAccount): ] ) - def post(self, content, reply_to_id=None, **kwargs): + def post( + self, + content, + reply_to_id=None, + obj: "Item | Content | None" = None, + rating=None, + **kwargs, + ): + from journal.models.renderers import render_rating + reply_to = None if reply_to_id: posts = self._client.get_posts([reply_to_id]).posts @@ -168,10 +180,30 @@ class BlueskyAccount(SocialAccount): reply_to = models.AppBskyFeedPost.ReplyRef( parent=root_post_ref, root=root_post_ref ) - text = client_utils.TextBuilder().text(content) - # todo OpenGraph - # .link("Python SDK", "https://atproto.blue") - post = self._client.send_post(text, reply_to=reply_to) + text = ( + content.replace("##rating##", render_rating(rating)) + .replace("##obj_link_if_plain##", "") + .split("##obj##") + ) + richtext = client_utils.TextBuilder() + first = True + for t in text: + if not first and obj: + richtext.link(obj.display_title, obj.absolute_url) + else: + first = False + richtext.text(t) + if obj: + embed = models.AppBskyEmbedExternal.Main( + external=models.AppBskyEmbedExternal.External( + title=obj.display_title, + description=obj.display_description, + uri=obj.absolute_url, + ) + ) + else: + embed = None + post = self._client.send_post(richtext, reply_to=reply_to, embed=embed) # return AT uri as id since it's used as so. return {"cid": post.cid, "id": post.uri} diff --git a/mastodon/models/mastodon.py b/mastodon/models/mastodon.py index 3de96b82..000e6db7 100644 --- a/mastodon/models/mastodon.py +++ b/mastodon/models/mastodon.py @@ -27,7 +27,8 @@ from takahe.utils import Takahe from .common import SocialAccount if typing.TYPE_CHECKING: - from journal.models.common import VisibilityType + from catalog.common.models import Item + from journal.models.common import Content, VisibilityType class TootVisibilityEnum(StrEnum): @@ -177,8 +178,6 @@ def post_toot2( "status": content, "visibility": visibility, } - # update_id = get_status_id_by_url(update_toot_url) - # reply_to_id = get_status_id_by_url(reply_to_toot_url) if reply_to_id: payload["in_reply_to_id"] = reply_to_id if spoiler_text: @@ -263,26 +262,6 @@ def random_string_generator(n): return "".join(random.choice(s) for i in range(n)) -def rating_to_emoji(score, star_mode=0): - """convert score to mastodon star emoji code""" - if score is None or score == "" or score == 0: - return "" - solid_stars = score // 2 - half_star = int(bool(score % 2)) - empty_stars = 5 - solid_stars if not half_star else 5 - solid_stars - 1 - if star_mode == 1: - emoji_code = "🌕" * solid_stars + "🌗" * half_star + "🌑" * empty_stars - else: - emoji_code = ( - settings.STAR_SOLID * solid_stars - + settings.STAR_HALF * half_star - + settings.STAR_EMPTY * empty_stars - ) - emoji_code = emoji_code.replace("::", ": :") - emoji_code = " " + emoji_code + " " - return emoji_code - - def verify_account(site, token): url = "https://" + get_api_domain(site) + API_VERIFY_ACCOUNT try: @@ -426,25 +405,6 @@ def obtain_token(site, code, request): return data.get("access_token"), data.get("refresh_token", "") -def get_status_id_by_url(url): - if not url: - return None - r = re.match( - r".+/(\w+)$", url - ) # might be re.match(r'.+/([^/]+)$', u) if Pleroma supports edit - return r[1] if r else None - - -def get_spoiler_text(text, item): - if text.find(">!") != -1: - spoiler_text = _( - "regarding {item_title}, may contain spoiler or triggering content" - ).format(item_title=item.display_title) - return spoiler_text, text.replace(">!", "").replace("!<", "") - else: - return None, text - - def get_toot_visibility(visibility, user) -> TootVisibilityEnum: if visibility == 2: return TootVisibilityEnum.DIRECT @@ -662,16 +622,18 @@ class MastodonAccount(SocialAccount): return app @functools.cached_property - def api_domain(self) -> str: + def _api_domain(self) -> str: app = self.application return app.api_domain if app else self.domain - def rating_to_emoji(self, rating_grade: int) -> str: - app = self.application - return rating_to_emoji(rating_grade, app.star_mode if app else 0) + def rating_to_emoji(self, rating_grade: int | None) -> str: + from journal.models.renderers import render_rating + + app = self.application # TODO fix star mode data flip in app + return render_rating(rating_grade, (0 if app.star_mode else 1) if app else 0) def _get(self, url: str): - url = url if url.startswith("https://") else f"https://{self.api_domain}{url}" + url = url if url.startswith("https://") else f"https://{self._api_domain}{url}" headers = { "User-Agent": settings.NEODB_USER_AGENT, "Authorization": f"Bearer {self.access_token}", @@ -679,7 +641,7 @@ class MastodonAccount(SocialAccount): return get(url, headers=headers) def _post(self, url: str, data, files=None): - url = url if url.startswith("https://") else f"https://{self.api_domain}{url}" + url = url if url.startswith("https://") else f"https://{self._api_domain}{url}" return post( url, data=data, @@ -692,7 +654,7 @@ class MastodonAccount(SocialAccount): ) def _delete(self, url: str, data, files=None): - url = url if url.startswith("https://") else f"https://{self.api_domain}{url}" + url = url if url.startswith("https://") else f"https://{self._api_domain}{url}" return delete( url, headers={ @@ -702,7 +664,7 @@ class MastodonAccount(SocialAccount): ) def _put(self, url: str, data, files=None): - url = url if url.startswith("https://") else f"https://{self.api_domain}{url}" + url = url if url.startswith("https://") else f"https://{self._api_domain}{url}" return put( url, data=data, @@ -814,19 +776,19 @@ class MastodonAccount(SocialAccount): ) def boost(self, post_url: str): - boost_toot(self.api_domain, self.access_token, post_url) + boost_toot(self._api_domain, self.access_token, post_url) def boost_later(self, post_url: str): django_rq.get_queue("fetch").enqueue( - boost_toot, self.api_domain, self.access_token, post_url + boost_toot, self._api_domain, self.access_token, post_url ) def delete_post(self, post_id: str): - delete_toot(self.api_domain, self.access_token, post_id) + delete_toot(self._api_domain, self.access_token, post_id) def delete_post_later(self, post_id: str): django_rq.get_queue("fetch").enqueue( - delete_toot, self.api_domain, self.access_token, post_id + delete_toot, self._api_domain, self.access_token, post_id ) def post( @@ -838,12 +800,21 @@ class MastodonAccount(SocialAccount): sensitive: bool = False, spoiler_text: str | None = None, attachments: list = [], + obj: "Item | Content | None" = None, + rating: int | None = None, ) -> dict: + from journal.models.renderers import render_rating + v = get_toot_visibility(visibility, self.user) + text = ( + content.replace("##rating##", self.rating_to_emoji(rating)) + .replace("##obj_link_if_plain##", obj.absolute_url + "\n" if obj else "") + .replace("##obj##", obj.display_title if obj else "") + ) response = post_toot2( - self.api_domain, + self._api_domain, self.access_token, - content, + text, v, update_id, reply_to_id, diff --git a/mastodon/models/threads.py b/mastodon/models/threads.py index b554fe66..77170993 100644 --- a/mastodon/models/threads.py +++ b/mastodon/models/threads.py @@ -1,4 +1,5 @@ import functools +import typing from datetime import timedelta from urllib.parse import quote @@ -14,6 +15,10 @@ from catalog.common import jsondata from .common import SocialAccount +if typing.TYPE_CHECKING: + from catalog.common.models import Item + from journal.models.common import Content, VisibilityType + get = functools.partial( requests.get, timeout=settings.THREADS_TIMEOUT, @@ -239,11 +244,21 @@ class ThreadsAccount(SocialAccount): self.save(update_fields=["account_data", "handle", "last_refresh"]) return True - def post(self, content: str, reply_to_id=None, **kwargs): + def post( + self, + content: str, + visibility: "VisibilityType", + reply_to_id=None, + obj: "Item | Content | None" = None, + rating=None, + **kwargs, + ): + from journal.models.renderers import render_rating + text = ( - content.replace(settings.STAR_SOLID + " ", "🌕") - .replace(settings.STAR_HALF + " ", "🌗") - .replace(settings.STAR_EMPTY + " ", "🌑") + content.replace("##rating##", render_rating(rating)) + .replace("##obj_link_if_plain##", obj.absolute_url + "\n" if obj else "") + .replace("##obj##", obj.display_title if obj else "") ) media_id = Threads.post_single(self.access_token, self.uid, text, reply_to_id) if not media_id: diff --git a/pyproject.toml b/pyproject.toml index b0f15d9a..52324d46 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,7 +56,7 @@ dependencies = [ "validators", "deepmerge>=1.1.1", "django-typed-models @ git+https://github.com/alphatownsman/django-typed-models.git", - "atproto>=0.0.48", + "atproto>=0.0.49", "pyright>=1.1.370", ] diff --git a/requirements-dev.lock b/requirements-dev.lock index c5d0d09b..19377e2b 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -20,7 +20,7 @@ asgiref==3.8.1 # via django # via django-cors-headers # via django-stubs -atproto==0.0.48 +atproto==0.0.49 attrs==23.2.0 # via aiohttp babel==2.15.0 diff --git a/requirements.lock b/requirements.lock index 30a4ac49..682831ec 100644 --- a/requirements.lock +++ b/requirements.lock @@ -19,7 +19,7 @@ anyio==4.4.0 asgiref==3.8.1 # via django # via django-cors-headers -atproto==0.0.48 +atproto==0.0.49 attrs==23.2.0 # via aiohttp beautifulsoup4==4.12.3 diff --git a/takahe/utils.py b/takahe/utils.py index 572de6f2..ece1f8c1 100644 --- a/takahe/utils.py +++ b/takahe/utils.py @@ -543,16 +543,6 @@ class Takahe: # TimelineEvent.objects.filter(subject_post__in=[post.pk]).delete() PostInteraction.objects.filter(post__in=post_pks).update(state="undone") - @staticmethod - def get_spoiler_text(text, item): - if text and text.find(">!") != -1: - spoiler_text = _( - "regarding {item_title}, may contain spoiler or triggering content" - ).format(item_title=item.display_title) - return spoiler_text, text.replace(">!", "").replace("!<", "") - else: - return None, text or "" - @staticmethod def visibility_n2t(visibility: int, post_public_mode: int) -> Visibilities: if visibility == 1: diff --git a/users/templates/users/preferences.html b/users/templates/users/preferences.html index cc840e73..7cc76fd5 100644 --- a/users/templates/users/preferences.html +++ b/users/templates/users/preferences.html @@ -108,14 +108,14 @@ required="" id="mastodon_repost_mode_0" {% if request.user.preference.mastodon_repost_mode == 0 %}checked{% endif %}> - + - + {% endif %}