bluesky post links and embeds

This commit is contained in:
Your Name 2024-07-05 16:26:26 -04:00 committed by Henri Dickson
parent 4e97cb7083
commit b727397cd1
16 changed files with 152 additions and 124 deletions

View file

@ -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]

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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 ""

View file

@ -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'<br><a href="{self.absolute_url}">{self.title}</a>'
)
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,

View file

@ -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'<a href="{item_link}">{self.item.display_title}</a>'
)
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,

View file

@ -6,7 +6,6 @@ from .mastodon import (
MastodonAccount,
MastodonApplication,
detect_server_info,
get_spoiler_text,
verify_client,
)
from .threads import Threads, ThreadsAccount

View file

@ -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}

View file

@ -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,

View file

@ -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:

View file

@ -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",
]

View file

@ -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

View file

@ -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

View file

@ -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:

View file

@ -108,14 +108,14 @@
required=""
id="mastodon_repost_mode_0"
{% if request.user.preference.mastodon_repost_mode == 0 %}checked{% endif %}>
<label for="mastodon_repost_mode_0">{% trans "boost" %}</label>
<label for="mastodon_repost_mode_0">{% trans "Boost if possible" %}</label>
<input type="radio"
name="mastodon_repost_mode"
value="1"
required=""
id="mastodon_repost_mode_1"
{% if request.user.preference.mastodon_repost_mode == 1 %}checked{% endif %}>
<label for="mastodon_repost_mode_1">{% trans "create a new post" %}</label>
<label for="mastodon_repost_mode_1">{% trans "Create a new post" %}</label>
<em data-tooltip="{% trans "this method is less optimal, may generate duplicated posts and miss reactions." %}"><i class="fa fa-question-circle"></i></em>
</fieldset>
{% endif %}