post to threads

This commit is contained in:
Your Name 2024-07-03 16:42:20 -04:00 committed by Henri Dickson
parent 2bd3aaa78d
commit 43e9db72b7
28 changed files with 1424 additions and 1169 deletions

View file

@ -227,6 +227,7 @@ ENABLE_LOCAL_ONLY = env("NEODB_ENABLE_LOCAL_ONLY")
# Timeout of requests to Mastodon, in seconds
MASTODON_TIMEOUT = env("NEODB_LOGIN_MASTODON_TIMEOUT", default=5) # type: ignore
THREADS_TIMEOUT = 10 # Threads is really slow when publishing post
TAKAHE_REMOTE_TIMEOUT = MASTODON_TIMEOUT
NEODB_USER_AGENT = f"NeoDB/{NEODB_VERSION} (+{SITE_INFO.get('site_url', 'undefined')})"

View file

@ -122,7 +122,12 @@
{% if messages %}
<ul class="messages" style="text-align:center">
{% for message in messages %}
<li {% if message.tags %}class="{{ message.tags }}"{% endif %}>{{ message }}</li>
<li {% if message.tags %}class="{{ message.tags }}"{% endif %}>
{% if message.meta.url %}
[<a href="{{ message.meta.url }}">{% trans "Open" %}</a>]
{% endif %}
{{ message }}
</li>
{% endfor %}
</ul>
{% endif %}

View file

@ -123,7 +123,7 @@ class Comment(Content):
ShelfManager.get_action_template(ShelfType.PROGRESS, self.item.category)
)
def to_mastodon_params(self):
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)

View file

@ -5,8 +5,11 @@ from datetime import datetime
from functools import cached_property
from typing import TYPE_CHECKING, Any, Self
import django_rq
# from deepmerge import always_merger
from django.conf import settings
from django.core.exceptions import PermissionDenied
from django.core.signing import b62_decode, b62_encode
from django.db import models
from django.db.models import CharField, Q
@ -14,10 +17,12 @@ from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from loguru import logger
from polymorphic.models import PolymorphicModel
from user_messages import api as messages
from catalog.common.models import Item, ItemCategory
from catalog.models import item_categories, item_content_types
from takahe.utils import Takahe
from users.middlewares import activate_language_for_user
from users.models import APIdentity, User
from .mixins import UserOwnedObjectMixin
@ -90,36 +95,10 @@ def q_item_in_category(item_category: ItemCategory):
return Q(item__polymorphic_ctype__in=contenttype_ids)
# class ImportStatus(Enum):
# QUEUED = 0
# PROCESSING = 1
# FINISHED = 2
# class ImportSession(models.Model):
# owner = models.ForeignKey(APIdentity, on_delete=models.CASCADE)
# status = models.PositiveSmallIntegerField(default=ImportStatus.QUEUED)
# importer = models.CharField(max_length=50)
# file = models.CharField()
# default_visibility = models.PositiveSmallIntegerField()
# total = models.PositiveIntegerField()
# processed = models.PositiveIntegerField()
# skipped = models.PositiveIntegerField()
# imported = models.PositiveIntegerField()
# failed = models.PositiveIntegerField()
# logs = models.JSONField(default=list)
# created_time = models.DateTimeField(auto_now_add=True)
# edited_time = models.DateTimeField(auto_now=True)
# class Meta:
# indexes = [
# models.Index(fields=["owner", "importer", "created_time"]),
# ]
class Piece(PolymorphicModel, UserOwnedObjectMixin):
if TYPE_CHECKING:
likes: models.QuerySet["Like"]
metadata: models.JSONField[Any, Any]
url_path = "p" # subclass must specify this
uid = models.UUIDField(default=uuid.uuid4, editable=False, db_index=True)
local = models.BooleanField(default=True)
@ -131,33 +110,16 @@ class Piece(PolymorphicModel, UserOwnedObjectMixin):
def classname(self) -> str:
return self.__class__.__name__.lower()
def get_mastodon_crosspost_url(self):
return (
(self.metadata or {}).get("shared_link")
if hasattr(self, "metadata")
else None
)
def set_mastodon_crosspost_url(self, url: str | None):
if not hasattr(self, "metadata"):
logger.warning("metadata field not found")
return
metadata = self.metadata or {}
if metadata.get("shared_link", None) == url:
return
if not url:
metadata.pop("shared_link", None)
else:
metadata["shared_link"] = url
self.metadata = metadata
self.save(update_fields=["metadata"])
def delete(self, *args, **kwargs):
if self.local:
Takahe.delete_posts(self.all_post_ids)
toot_url = self.get_mastodon_crosspost_url()
if toot_url and self.owner.user.mastodon:
self.owner.user.mastodon.delete_later(toot_url)
toot_id = (
(self.metadata or {}).get("mastodon_id")
if hasattr(self, "metadata")
else None
)
if toot_id and self.owner.user.mastodon:
self.owner.user.mastodon.delete_post_later(toot_id)
return super().delete(*args, **kwargs)
@property
@ -265,7 +227,7 @@ class Piece(PolymorphicModel, UserOwnedObjectMixin):
return {}
@abstractmethod
def to_mastodon_params(self) -> dict[str, Any]:
def to_crosspost_params(self) -> dict[str, Any]:
return {}
@classmethod
@ -314,65 +276,89 @@ class Piece(PolymorphicModel, UserOwnedObjectMixin):
# subclass may have to add additional code to update type_data in local post
return p
def sync_to_mastodon(self, delete_existing=False):
user = self.owner.user
if not user.mastodon:
def get_crosspost_params(self):
d = {
"visibility": self.visibility,
"update_id": (self.metadata if hasattr(self, "metadata") else {}).get(
"mastodon_id"
),
}
d.update(self.to_crosspost_params())
return d
def sync_to_social_accounts(self, update_mode: int = 0):
"""update_mode: 0 update if exists otherwise create; 1: delete if exists and create; 2: only create"""
django_rq.get_queue("mastodon").enqueue(
self._sync_to_social_accounts, update_mode
)
def _sync_to_social_accounts(self, update_mode: int):
activate_language_for_user(self.owner.user)
params = self.get_crosspost_params()
metadata = self.metadata.copy()
self.sync_to_mastodon(params, update_mode)
self.sync_to_threads(params, update_mode)
if self.metadata != metadata:
self.save(update_fields=["metadata"])
def sync_to_threads(self, params, update_mode):
# skip non-public post as Threads does not support it
# update_mode will be ignored as update/delete are not supported either
threads = self.owner.user.threads
if params["visibility"] != 0 or not threads:
return
if user.preference.mastodon_repost_mode == 1:
if delete_existing:
self.delete_mastodon_repost()
return self.crosspost_to_mastodon()
try:
r = threads.post(**params)
except Exception:
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()})
return True
def sync_to_mastodon(self, params, update_mode):
mastodon = self.owner.user.mastodon
if not mastodon:
return
if self.owner.user.preference.mastodon_repost_mode == 1:
if update_mode == 1:
toot_id = self.metadata.pop("mastodon_id", None)
if toot_id:
self.metadata.pop("mastodon_url", None)
mastodon.delete(toot_id)
elif update_mode == 1:
params.pop("update_id", None)
return self.crosspost_to_mastodon(params)
elif self.latest_post:
if user.mastodon:
user.mastodon.boost_later(self.latest_post.url)
mastodon.boost(self.latest_post.url)
else:
logger.warning("No post found for piece")
def delete_mastodon_repost(self):
toot_url = self.get_mastodon_crosspost_url()
if toot_url:
self.set_mastodon_crosspost_url(None)
if self.owner.user.mastodon:
self.owner.user.mastodon.delete_later(toot_url)
def crosspost_to_mastodon(self, params):
mastodon = self.owner.user.mastodon
if not mastodon:
return False
r = mastodon.post(**params)
try:
pass
except PermissionDenied:
messages.error(
mastodon.user,
_("A recent post was not posted to Mastodon, please re-authorize."),
meta={"url": mastodon.get_reauthorize_url()},
)
return False
except Exception:
logger.warning(f"{self} post to {mastodon} failed")
messages.error(
mastodon.user, _("A recent post was not posted to Mastodon.")
)
return False
self.metadata.update({"mastodon_" + k: v for k, v in r.items()})
return True
def crosspost_to_mastodon(self):
user = self.owner.user
if not user or not user.mastodon:
return False, -1
d = {
"visibility": self.visibility,
"update_toot_url": self.get_mastodon_crosspost_url(),
}
d.update(self.to_mastodon_params())
response = user.mastodon.post(**d)
if response is not None and response.status_code in [200, 201]:
j = response.json()
if "url" in j:
metadata = {"shared_link": j["url"]}
if self.metadata != metadata:
self.metadata = metadata
self.save(update_fields=["metadata"])
return True, 200
else:
logger.warning(response)
return False, response.status_code if response is not None else -1
def sync_to_timeline(self, delete_existing=False):
user = self.owner.user
v = Takahe.visibility_n2t(self.visibility, user.preference.post_public_mode)
existing_post = self.latest_post
if existing_post and existing_post.state in ["deleted", "deleted_fanned_out"]:
existing_post = None
elif existing_post and delete_existing:
Takahe.delete_posts([existing_post.pk])
existing_post = None
params = {
"author_pk": self.owner.pk,
"visibility": v,
"post_pk": existing_post.pk if existing_post else None,
"post_time": self.created_time, # type:ignore subclass must have this
"edit_time": self.edited_time, # type:ignore subclass must have this
"data": {
def get_ap_data(self):
return {
"object": {
"tag": (
[self.item.ap_object_ref] # type:ignore
@ -381,7 +367,29 @@ class Piece(PolymorphicModel, UserOwnedObjectMixin):
),
"relatedWith": [self.ap_object],
}
},
}
def sync_to_timeline(self, update_mode: int = 0):
"""update_mode: 0 update if exists otherwise create; 1: delete if exists and create; 2: only create"""
user = self.owner.user
v = Takahe.visibility_n2t(self.visibility, user.preference.post_public_mode)
existing_post = self.latest_post
if existing_post:
if (
existing_post.state in ["deleted", "deleted_fanned_out"]
or update_mode == 2
):
existing_post = None
elif update_mode == 1:
Takahe.delete_posts([existing_post.pk])
existing_post = None
params = {
"author_pk": self.owner.pk,
"visibility": v,
"post_pk": existing_post.pk if existing_post else None,
"post_time": self.created_time, # type:ignore subclass must have this
"edit_time": self.edited_time, # type:ignore subclass must have this
"data": self.get_ap_data(),
}
params.update(self.to_post_params())
post = Takahe.post(**params)

View file

@ -18,33 +18,6 @@ from .review import Review
from .shelf import Shelf, ShelfLogEntry, ShelfManager, ShelfMember, ShelfType
def share_mark(mark, post_as_new=False):
user = mark.owner.user
if not user or not user.mastodon:
return
stars = user.mastodon.rating_to_emoji(mark.rating_grade)
spoiler_text, txt = get_spoiler_text(mark.comment_text or "", mark.item)
content = f"{mark.get_action_for_feed()} {stars}\n{mark.item.absolute_url}\n{txt}{mark.tag_text}"
update_url = (
None if post_as_new else (mark.shelfmember.metadata or {}).get("shared_link")
)
response = user.mastodon.post(
content,
mark.visibility,
update_url,
spoiler_text,
)
if response is not None and response.status_code in [200, 201]:
j = response.json()
if "url" in j:
mark.shelfmember.metadata = {"shared_link": j["url"]}
mark.shelfmember.save(update_fields=["metadata"])
return True, 200
else:
logger.warning(response)
return False, response.status_code if response is not None else -1
class Mark:
"""
Holding Mark for an item on an shelf,
@ -241,6 +214,7 @@ class Mark:
visibility = self.visibility
last_shelf_type = self.shelf_type
last_visibility = self.visibility if last_shelf_type else None
update_mode = 0
if tags is not None:
self.owner.tag_manager.tag_item(self.item, tags, visibility)
if shelf_type is None:
@ -264,8 +238,7 @@ class Mark:
self.shelfmember.visibility = visibility
shelfmember_changed = True
# retract most recent post about this status when visibility changed
if self.shelfmember.latest_post:
Takahe.delete_posts([self.shelfmember.latest_post.pk])
update_mode = 1
if created_time and created_time != self.shelfmember.created_time:
self.shelfmember.created_time = created_time
log_entry.timestamp = created_time
@ -277,6 +250,8 @@ class Mark:
if shelfmember_changed:
self.shelfmember.save()
else:
# ignore most recent post if exists and create new one
update_mode = 2
shelf = Shelf.objects.get(owner=self.owner, shelf_type=shelf_type)
d = {"parent": shelf, "visibility": visibility, "position": 0}
if metadata:
@ -305,28 +280,17 @@ class Mark:
)
self.rating_grade = rating_grade
# publish a new or updated ActivityPub post
user: User = self.owner.user
post_as_new = shelf_type != last_shelf_type or visibility != last_visibility
classic_crosspost = user.preference.mastodon_repost_mode == 1
append = (
f"@{user.mastodon.handle}\n"
if visibility > 0
and share_to_mastodon
and not classic_crosspost
and user.mastodon
else ""
)
post = Takahe.post_mark(self, post_as_new, append)
if post and self.item.category in (user.preference.auto_bookmark_cats or []):
if shelf_type == ShelfType.PROGRESS:
post = self.shelfmember.sync_to_timeline(update_mode)
if share_to_mastodon:
self.shelfmember.sync_to_social_accounts(update_mode)
# auto add bookmark
if (
post
and shelf_type == ShelfType.PROGRESS
and self.item.category
in (self.owner.user.preference.auto_bookmark_cats or [])
):
Takahe.bookmark(post.pk, self.owner.pk)
# async boost to mastodon
if post and share_to_mastodon:
if classic_crosspost:
share_mark(self, post_as_new)
elif user.mastodon:
user.mastodon.boost_later(post.url)
return True
def delete(self, keep_tags=False):
self.update(None, tags=None if keep_tags else [])

View file

@ -160,21 +160,21 @@ class Note(Content):
# if local piece is created from a post, update post type_data and fanout
p.sync_to_timeline()
if owner.user.preference.mastodon_default_repost and owner.user.mastodon:
p.sync_to_mastodon()
p.sync_to_social_accounts()
return p
@cached_property
def shelfmember(self) -> ShelfMember | None:
return ShelfMember.objects.filter(item=self.item, owner=self.owner).first()
def to_mastodon_params(self):
def to_crosspost_params(self):
footer = f"\n\n{self.item.display_title}{self.progress_display}\n{self.item.absolute_url}"
params = {
"spoiler_text": self.title,
"content": self.content + footer,
"sensitive": self.sensitive,
"reply_to_toot_url": (
self.shelfmember.get_mastodon_crosspost_url()
"reply_to_id": (
(self.shelfmember.metadata or {}).get("mastodon_id")
if self.shelfmember
else None
),

View file

@ -86,7 +86,7 @@ class Review(Content):
def get_crosspost_template(self):
return _(ShelfManager.get_action_template("reviewed", self.item.category))
def to_mastodon_params(self):
def to_crosspost_params(self):
content = (
self.get_crosspost_template().format(item=self.item.display_title)
+ f"\n{self.title}\n{self.absolute_url} "
@ -153,7 +153,8 @@ class Review(Content):
review, created = cls.objects.update_or_create(
item=item, owner=owner, defaults=defaults
)
review.sync_to_timeline(delete_existing=delete_existing_post)
update_mode = 1 if delete_existing_post else 0
review.sync_to_timeline(update_mode)
if share_to_mastodon:
review.sync_to_mastodon(delete_existing=delete_existing_post)
review.sync_to_social_accounts(update_mode)
return review

View file

@ -2,11 +2,10 @@ from datetime import datetime
from functools import cached_property
from typing import TYPE_CHECKING
from django.conf import settings
from django.db import connection, models
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django.utils.translation import pgettext_lazy
from django.utils.translation import pgettext_lazy as __
from loguru import logger
from catalog.models import Item, ItemCategory
@ -15,9 +14,12 @@ 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
if TYPE_CHECKING:
from .comment import Comment
from .mark import Mark
from .rating import Rating
class ShelfType(models.TextChoices):
@ -356,6 +358,71 @@ class ShelfMember(ListMember):
p.link_post_id(post.id)
return p
def get_crosspost_postfix(self):
tags = render_post_with_macro(
self.owner.user.preference.mastodon_append_tag, self.item
)
return "\n" + tags if tags else ""
def get_crosspost_template(self):
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)
if self.sibling_comment:
spoiler, txt = Takahe.get_spoiler_text(self.sibling_comment.text, self.item)
else:
spoiler, txt = None, ""
stars = (
self.owner.user.mastodon.rating_to_emoji(self.sibling_rating.grade)
if self.sibling_rating and self.owner.user.mastodon
else ""
)
content = f"{action} {stars} \n{self.item.absolute_url}\n{txt}\n{self.get_crosspost_postfix()}"
params = {"content": content, "spoiler_text": spoiler}
return params
def to_post_params(self):
item_link = f"{settings.SITE_INFO['site_url']}/~neodb~{self.item.url}"
action = self.get_crosspost_template().format(
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)
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}"
return {
"prepend_content": action,
"content": content,
"summary": spoiler,
"sensitive": spoiler is not None,
}
def get_ap_data(self):
data = super().get_ap_data()
if self.sibling_comment:
data["object"]["relatedWith"].append(self.sibling_comment.ap_object)
if self.sibling_rating:
data["object"]["relatedWith"].append(self.sibling_rating.ap_object)
return data
@cached_property
def sibling_comment(self) -> "Comment | None":
from .comment import Comment
return Comment.objects.filter(owner=self.owner, item=self.item).first()
@cached_property
def sibling_rating(self) -> "Rating | None":
from .rating import Rating
return Rating.objects.filter(owner=self.owner, item=self.item).first()
@cached_property
def mark(self) -> "Mark":
from .mark import Mark
@ -405,6 +472,7 @@ class ShelfMember(ListMember):
def link_post_id(self, post_id: int):
if self.local:
self.ensure_log_entry().link_post_id(post_id)
print(self.ensure_log_entry(), post_id)
return super().link_post_id(post_id)
@ -461,6 +529,11 @@ class ShelfLogEntry(models.Model):
def link_post_id(self, post_id: int):
ShelfLogEntryPost.objects.get_or_create(log_entry=self, post_id=post_id)
def all_post_ids(self):
return ShelfLogEntryPost.objects.filter(log_entry=self).values_list(
"post_id", flat=True
)
class ShelfLogEntryPost(models.Model):
log_entry = models.ForeignKey(ShelfLogEntry, on_delete=models.CASCADE)

View file

@ -109,7 +109,7 @@
</div>
<div>
<fieldset>
{% if request.user.mastodon %}
{% if request.user.mastodon or request.user.threads or request.user.bluesky %}
<label for="id_share_to_mastodon">
<input role="switch"
type="checkbox"

View file

@ -180,10 +180,10 @@ def share_collection(
)
)
content = f"{user_str}:{collection.title}\n{link}\n{comment}{tags}"
response = user.mastodon.post(content, visibility)
if response is not None and response.status_code in [200, 201]:
try:
user.mastodon.post(content, visibility)
return True
else:
except Exception:
return False

View file

@ -183,9 +183,10 @@ def comment(request: AuthedHttpRequest, item_uuid):
comment = Comment.objects.update_or_create(
owner=request.user.identity, item=item, defaults=d
)[0]
comment.sync_to_timeline(delete_existing=delete_existing_post)
update_mode = 1 if delete_existing_post else 0
comment.sync_to_timeline(update_mode)
if share_to_mastodon:
comment.sync_to_mastodon(delete_existing=delete_existing_post)
comment.sync_to_social_accounts(update_mode)
return HttpResponseRedirect(request.META.get("HTTP_REFERER", "/"))

View file

@ -108,7 +108,8 @@ def note_edit(request: AuthedHttpRequest, item_uuid: str, note_uuid: str = ""):
delete_existing_post = (
orig_visibility is not None and orig_visibility != note.visibility
)
note.sync_to_timeline(delete_existing=delete_existing_post)
update_mode = 1 if delete_existing_post else 0
note.sync_to_timeline(update_mode)
if form.cleaned_data["share_to_mastodon"]:
note.sync_to_mastodon(delete_existing=delete_existing_post)
note.sync_to_social_accounts(update_mode)
return HttpResponseRedirect(request.META.get("HTTP_REFERER", "/"))

View file

@ -129,9 +129,12 @@ class WrappedShareView(LoginRequiredMixin, TemplateView):
)
classic_crosspost = user.preference.mastodon_repost_mode == 1
if classic_crosspost and user.mastodon:
try:
user.mastodon.post(
comment, visibility, attachments=[("year.png", img, "image/png")]
)
except Exception:
pass
elif post and user.mastodon:
user.mastodon.boost_later(post.url)
messages.add_message(request, messages.INFO, _("Summary posted to timeline."))

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -10,6 +10,7 @@ import django_rq
import requests
from django.conf import settings
from django.core.cache import cache
from django.core.exceptions import PermissionDenied, RequestAborted
from django.db import models
from django.db.models import Count
from django.http import HttpRequest
@ -139,12 +140,11 @@ def boost_toot(domain, token, toot_url):
return None
def delete_toot(api_domain, access_token, toot_url):
def delete_toot(api_domain, access_token, toot_id):
headers = {
"User-Agent": USER_AGENT,
"Authorization": f"Bearer {access_token}",
}
toot_id = get_status_id_by_url(toot_url)
url = "https://" + api_domain + API_PUBLISH_TOOT + "/" + toot_id
try:
response = delete(url, headers=headers)
@ -159,8 +159,8 @@ def post_toot2(
access_token: str,
content: str,
visibility: TootVisibilityEnum,
update_toot_url: str | None = None,
reply_to_toot_url: str | None = None,
update_id: str | None = None,
reply_to_id: str | None = None,
sensitive: bool = False,
spoiler_text: str | None = None,
attachments: list = [],
@ -177,8 +177,8 @@ 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)
# 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:
@ -415,7 +415,9 @@ def obtain_token(site, code, request):
try:
response = post(url, data=payload, headers=headers, auth=auth)
if response.status_code != 200:
logger.warning(f"Error {url} {response.status_code}")
logger.warning(
f"Error {url} {payload} {response.status_code} {response.content}"
)
return None, None
except Exception as e:
logger.warning(f"Error {url} {e}")
@ -467,6 +469,13 @@ def get_or_create_fediverse_application(login_domain):
if not app:
app = MastodonApplication.objects.filter(api_domain__iexact=domain).first()
if app:
if " Firefish " in app.server_version:
data = create_app(app.api_domain, True).json()
app.app_id = data["id"]
app.client_id = data["client_id"]
app.client_secret = data["client_secret"]
app.vapid_key = data.get("vapid_key", "")
app.save()
return app
if not settings.MASTODON_ALLOW_ANY_SITE:
logger.warning(f"Disallowed to create app for {domain}")
@ -804,38 +813,55 @@ class MastodonAccount(SocialAccount):
]
)
def boost(self, post_url: str):
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
)
def delete_later(self, post_url: str):
def delete_post(self, post_id: str):
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_url
delete_toot, self.api_domain, self.access_token, post_id
)
def post(
self,
content: str,
visibility: "VisibilityType",
update_toot_url: str | None = None,
reply_to_toot_url: str | None = None,
update_id: str | None = None,
reply_to_id: str | None = None,
sensitive: bool = False,
spoiler_text: str | None = None,
attachments: list = [],
) -> requests.Response | None:
) -> dict:
v = get_toot_visibility(visibility, self.user)
return post_toot2(
response = post_toot2(
self.api_domain,
self.access_token,
content,
v,
update_toot_url,
reply_to_toot_url,
update_id,
reply_to_id,
sensitive,
spoiler_text,
attachments,
)
if response:
if response.status_code in [200, 201]:
j = response.json()
return {"id": j["id"], "url": j["url"]}
elif response.status_code == 401:
raise PermissionDenied()
raise RequestAborted()
def sync_later(self):
Takahe.fetch_remote_identity(self.handle)
# TODO
def get_reauthorize_url(self):
return reverse("mastodon:connect") + "?domain=" + self.domain

View file

@ -1,8 +1,10 @@
import functools
from datetime import timedelta
from urllib.parse import quote
import requests
from django.conf import settings
from django.core.exceptions import RequestAborted
from django.http import HttpRequest
from django.urls import reverse
from django.utils import timezone
@ -14,22 +16,22 @@ from .common import SocialAccount
get = functools.partial(
requests.get,
timeout=settings.MASTODON_TIMEOUT,
timeout=settings.THREADS_TIMEOUT,
headers={"User-Agent": settings.NEODB_USER_AGENT},
)
put = functools.partial(
requests.put,
timeout=settings.MASTODON_TIMEOUT,
timeout=settings.THREADS_TIMEOUT,
headers={"User-Agent": settings.NEODB_USER_AGENT},
)
post = functools.partial(
requests.post,
timeout=settings.MASTODON_TIMEOUT,
timeout=settings.THREADS_TIMEOUT,
headers={"User-Agent": settings.NEODB_USER_AGENT},
)
delete = functools.partial(
requests.post,
timeout=settings.MASTODON_TIMEOUT,
timeout=settings.THREADS_TIMEOUT,
headers={"User-Agent": settings.NEODB_USER_AGENT},
)
@ -109,7 +111,7 @@ class Threads:
def get_profile(
token: str, user_id: str | None = None
) -> dict[str, str | int] | None:
url = f'https://graph.threads.net/v1.0/{user_id or "me"}?fields=id,username,name,threads_profile_picture_url,threads_biography&access_token={token}'
url = f'https://graph.threads.net/v1.0/{user_id or "me"}?fields=id,username,threads_profile_picture_url,threads_biography&access_token={token}'
try:
response = get(url)
except Exception as e:
@ -124,6 +126,30 @@ class Threads:
return None
return data
@staticmethod
def post_single(token: str, user_id: str, text: str):
url = f"https://graph.threads.net/v1.0/{user_id}/threads?media_type=TEXT&access_token={token}&text={quote(text)}"
response = post(url)
if response.status_code != 200:
return None
media_container_id = (response.json() or {}).get("id")
if not media_container_id:
return None
url = f"https://graph.threads.net/v1.0/{user_id}/threads_publish?creation_id={media_container_id}&access_token={token}"
response = post(url)
if response.status_code != 200:
return None
return (response.json() or {}).get("id")
@staticmethod
def get_single(token: str, media_id: str) -> dict | None:
# url = f"https://graph.threads.net/v1.0/{media_id}?fields=id,media_product_type,media_type,media_url,permalink,owner,username,text,timestamp,shortcode,thumbnail_url,children,is_quote_post&access_token={token}"
url = f"https://graph.threads.net/v1.0/{media_id}?fields=id,permalink,is_quote_post&access_token={token}"
response = post(url)
if response.status_code != 200:
return None
return response.json()
@staticmethod
def authenticate(request: HttpRequest, code: str) -> "ThreadsAccount | None":
token, expire, uid = Threads.obtain_token(request, code)
@ -195,7 +221,7 @@ class ThreadsAccount(SocialAccount):
return False
data = Threads.get_profile(self.access_token)
if not data:
logger.warning("{self} unable to get profile")
logger.warning(f"{self} unable to get profile")
return False
if self.handle != data["username"]:
if self.handle:
@ -206,3 +232,16 @@ class ThreadsAccount(SocialAccount):
if save:
self.save(update_fields=["account_data", "handle", "last_refresh"])
return True
def post(self, content: str, **kwargs):
text = (
content.replace(settings.STAR_SOLID + " ", "🌕")
.replace(settings.STAR_HALF + " ", "🌗")
.replace(settings.STAR_EMPTY + " ", "🌑")
)
media_id = Threads.post_single(self.access_token, self.uid, text)
if media_id:
d = Threads.get_single(self.access_token, media_id)
if d:
return {"id": media_id, "url": d["permalink"]}
raise RequestAborted()

View file

@ -8,7 +8,7 @@ urlpatterns = [
path("login/oauth", mastodon_oauth, name="oauth"),
path("mastodon/login", mastodon_login, name="login"),
path("mastodon/reconnect", mastodon_reconnect, name="reconnect"),
path("mastodon/disconnect", mastodon_disconnect, name="mastodon_disconnect"),
path("mastodon/disconnect", mastodon_disconnect, name="disconnect"),
# Email
path("email/login", email_login, name="email_login"),
path("email/verify", email_verify, name="email_verify"),

View file

@ -80,7 +80,8 @@ def disconnect_identity(request, account):
with transaction.atomic():
if request.user.social_accounts.all().count() <= 1:
return render_error(
_("Unlink identity failed"), _("Unable to unlink last login identity.")
_("Disconnect identity failed"),
_("You cannot disconnect last login identity."),
)
account.delete()
return redirect(reverse("users:info"))

View file

@ -49,9 +49,13 @@ def threads_oauth(request: HttpRequest):
@require_http_methods(["GET"])
def threads_uninstall(request: HttpRequest):
print(request.GET)
print(request.POST)
return redirect(reverse("users:data"))
@require_http_methods(["GET"])
def threads_delete(request: HttpRequest):
print(request.GET)
print(request.POST)
return redirect(reverse("users:data"))

View file

@ -57,6 +57,7 @@ dependencies = [
"deepmerge>=1.1.1",
"django-typed-models @ git+https://github.com/alphatownsman/django-typed-models.git",
"atproto>=0.0.48",
"pyright>=1.1.370",
]
[tool.rye]
@ -94,7 +95,7 @@ django_settings_module = "boofilsic.settings"
[tool.ruff]
exclude = ["neodb-takahe/*", "media", ".venv", ".git", "playground", "**/tests.py", "neodb", "**/migrations", "**/commands", "**/importers", "**/sites", "legacy" ]
lint.ignore = ["F403", "F405"]
lint.ignore = ["F401", "F403", "F405"]
[tool.setuptools]
py-modules = []

View file

@ -229,7 +229,7 @@ pygments==2.18.0
# via mkdocs-material
pymdown-extensions==10.8.1
# via mkdocs-material
pyright==1.1.369
pyright==1.1.370
python-dateutil==2.9.0.post0
# via dateparser
# via django-auditlog

View file

@ -126,6 +126,8 @@ mistune==3.0.2
multidict==6.0.5
# via aiohttp
# via yarl
nodeenv==1.9.1
# via pyright
oauthlib==3.2.2
# via django-oauth-toolkit
openpyxl==3.1.3
@ -146,6 +148,7 @@ pydantic==2.7.3
# via django-ninja
pydantic-core==2.18.4
# via pydantic
pyright==1.1.370
python-dateutil==2.9.0.post0
# via dateparser
# via django-auditlog

View file

@ -8,7 +8,7 @@
{% load user_actions %}
{% load duration %}
{% for event in events %}
{% with post=event.subject_post piece=event.subject_post.piece item=event.subject_post.piece.item %}
{% with post=event.subject_post piece=event.subject_post.piece item=event.subject_post.item %}
{% if not post %}
<!-- invalid data {{ event.pk }} -->
{% else %}
@ -70,7 +70,7 @@
{% trans "wrote a note" %}
{% endif %}
</span>
{% if piece and item and piece.classname != 'note' %}
{% if item and piece.classname != 'note' %}
<article>{% include "_item_card.html" with item=item allow_embed=1 %}</article>
{% endif %}
<div>{{ post.summary|default:'' }}</div>

View file

@ -1092,6 +1092,16 @@ class Post(models.Model):
return pcs[0]
return next((p for p in pcs if p.__class__ == ShelfMember), None)
@cached_property
def item(self):
from journal.models import ShelfLogEntry
p = self.piece
if p:
return p.item if hasattr(p, "item") else None # type:ignore
log = ShelfLogEntry.objects.filter(shelflogentrypost__post_id=self.pk).first()
return log.item if log else None
@classmethod
def create_local(
cls,

View file

@ -626,51 +626,6 @@ class Takahe:
collection.link_post_id(post.pk)
return post
@staticmethod
def post_mark(mark, share_as_new_post: bool, append_content="") -> Post | None:
user = mark.owner.user
stars = _rating_to_emoji(mark.rating_grade, 1)
item_link = f"{settings.SITE_INFO['site_url']}/~neodb~{mark.item.url}"
prepend_content = mark.get_action_for_feed(item_link=item_link)
spoiler, txt = Takahe.get_spoiler_text(mark.comment_text, mark.item)
content = f"{stars} \n{txt}\n{mark.tag_text}"
data = {
"object": {
"tag": [mark.item.ap_object_ref],
"relatedWith": [mark.shelfmember.ap_object],
}
}
if mark.comment:
data["object"]["relatedWith"].append(mark.comment.ap_object)
if mark.rating:
data["object"]["relatedWith"].append(mark.rating.ap_object)
v = Takahe.visibility_n2t(mark.visibility, user.preference.post_public_mode)
existing_post = (
None
if share_as_new_post
or mark.shelfmember.latest_post is None
or mark.shelfmember.latest_post.state in ["deleted", "deleted_fanned_out"]
else mark.shelfmember.latest_post
)
post = Takahe.post(
mark.owner.pk,
content + append_content,
v,
prepend_content,
"",
spoiler,
spoiler is not None,
data,
existing_post.pk if existing_post else None,
mark.shelfmember.created_time,
)
if not post:
return
for piece in [mark.shelfmember, mark.comment, mark.rating]:
if piece:
piece.link_post_id(post.pk)
return post
@staticmethod
def interact_post(post_pk: int, identity_pk: int, type: str):
post = Post.objects.filter(pk=post_pk).first()

View file

@ -14,23 +14,43 @@
{% include "_header.html" %}
<main>
<div class="grid__main">
<article>
<details>
<summary>{% trans "Display name, avatar and other information" %}</summary>
<form action="{% url 'users:profile' %}?next={{ request.path }}"
method="post"
{% if request.user.mastodon and not request.user.preference.mastodon_skip_userinfo %}onsubmit="return confirm('{% trans "Updating profile information here will turn off automatic sync of display name, bio and avatar from your Mastodon instance. Sure to continue?" %}')"{% endif %}
enctype="multipart/form-data">
<label>
{% trans "Username" %}
<input value="{{ request.user.username }}"
aria-invalid="false"
readonly
disabled />
</label>
{% include "_field.html" with field=profile_form.name %}
{% include "_field.html" with field=profile_form.summary %}
{% include "_field.html" with field=profile_form.icon %}
{% include "_field.html" with field=profile_form.discoverable %}
{% include "_field.html" with field=profile_form.manually_approves_followers %}
{% csrf_token %}
<input type="submit" value="{% trans 'Save' %}" id="save">
</form>
</details>
</article>
{% if allow_any_site %}
<article>
<details>
<summary>{% trans 'Username and Email' %}</summary>
<summary>{% trans 'Email' %}</summary>
<form action="{% url 'users:register' %}?next={{ request.path }}"
method="post">
<small>{{ error }}</small>
<input value="{{ request.user.username }}" type="hidden" name="username" />
<fieldset>
<label>
{% trans "Username" %}
<input name="username" _="on input remove [@disabled] from #save end" placeholder="{% trans "2-30 alphabets, numbers or underscore, can't be changed once saved" %}" required {% if request.user.username %}value="{{ request.user.username }}" aria-invalid="false" readonly{% endif %} pattern="^[a-zA-Z0-9_]{2,30}$" />
</label>
<label>
{% trans "Email address" %}
<input type="email"
name="email"
_="on input remove [@disabled] from #save then remove [@aria-invalid] end"
_="on input remove [@disabled] from #save_email then remove [@aria-invalid] end"
{% if request.user.email_account %}value="{{ request.user.email_account.handle }}" aria-invalid="false"{% endif %}
placeholder="email"
autocomplete="email" />
@ -44,7 +64,7 @@
</label>
</fieldset>
{% csrf_token %}
<input type="submit" value="{% trans 'Save' %}" disabled id="save">
<input type="submit" value="{% trans 'Save' %}" disabled id="save_email">
</form>
</details>
</article>
@ -97,6 +117,16 @@
</small>
</fieldset>
</form>
{% if request.user.mastodon %}
<form action="{% url 'mastodon:disconnect' %}"
method="post"
onsubmit="return confirm('{% trans "Once disconnected, you will no longer be able login with this identity. Are you sure to continue?" %}')">
{% csrf_token %}
<input type="submit"
value="{% trans 'Disconnect with this identity' %}"
class="secondary" />
</form>
{% endif %}
</details>
</article>
<article>
@ -120,59 +150,22 @@
</label>
{% endif %}
<input type="submit"
{% if request.user.threads %} value="{% trans 'Link with a different threads.net account' %}" {% else %} {% trans "Link with a threads.net account" %} {% endif %} />
value="{% if request.user.threads %} {% trans 'Link with a different threads.net account' %} {% else %} {% trans "Link with a threads.net account" %} {% endif %} " />
</fieldset>
</form>
{% if request.user.threads %}
<form action="{% url 'mastodon:threads_login' %}"
method="post"
onsubmit="return confirm('{% trans "Once disconnected, you will no longer be able login with this identity. Are you sure to continue?" %}')">
{% csrf_token %}
<input type="submit"
value="{% trans 'Disconnect with Threads' %}"
class="secondary" />
</form>
{% endif %}
</details>
</article>
{% endif %}
<article>
<details>
<summary>{% trans "Display name, avatar and other information" %}</summary>
<form action="{% url 'users:profile' %}?next={{ request.path }}"
method="post"
{% if request.user.mastodon and not request.user.preference.mastodon_skip_userinfo %}onsubmit="return confirm('{% trans "Updating profile information here will turn off automatic sync of display name, bio and avatar from your Mastodon instance. Sure to continue?" %}')"{% endif %}
enctype="multipart/form-data">
{% include "_field.html" with field=profile_form.name %}
{% include "_field.html" with field=profile_form.summary %}
{% include "_field.html" with field=profile_form.icon %}
{% include "_field.html" with field=profile_form.discoverable %}
{% include "_field.html" with field=profile_form.manually_approves_followers %}
{% csrf_token %}
<input type="submit" value="{% trans 'Save' %}" id="save">
</form>
</details>
</article>
<article>
<details>
<summary>{% trans 'Users you are following' %}</summary>
{% include 'users/relationship_list.html' with id="follow" list=request.user.identity.following_identities.all %}
</details>
</article>
<article>
<details>
<summary>{% trans 'Users who follow you' %}</summary>
{% include 'users/relationship_list.html' with id="follower" list=request.user.identity.follower_identities.all %}
</details>
</article>
<article>
<details>
<summary>{% trans 'Users who request to follow you' %}</summary>
{% include 'users/relationship_list.html' with id="follow_request" list=request.user.identity.requested_follower_identities.all %}
</details>
</article>
<article>
<details>
<summary>{% trans 'Users you are muting' %}</summary>
{% include 'users/relationship_list.html' with id="mute" list=request.user.identity.muting_identities.all %}
</details>
</article>
<article>
<details>
<summary>{% trans 'Users you are blocking' %}</summary>
{% include 'users/relationship_list.html' with id="block" list=request.user.identity.blocking_identities.all %}
</details>
</article>
<article>
<details>
<summary>{% trans 'Sync and import social account' %}</summary>
@ -219,6 +212,36 @@
</form>
</details>
</article>
<article>
<details>
<summary>{% trans 'Users you are following' %}</summary>
{% include 'users/relationship_list.html' with id="follow" list=request.user.identity.following_identities.all %}
</details>
</article>
<article>
<details>
<summary>{% trans 'Users who follow you' %}</summary>
{% include 'users/relationship_list.html' with id="follower" list=request.user.identity.follower_identities.all %}
</details>
</article>
<article>
<details>
<summary>{% trans 'Users who request to follow you' %}</summary>
{% include 'users/relationship_list.html' with id="follow_request" list=request.user.identity.requested_follower_identities.all %}
</details>
</article>
<article>
<details>
<summary>{% trans 'Users you are muting' %}</summary>
{% include 'users/relationship_list.html' with id="mute" list=request.user.identity.muting_identities.all %}
</details>
</article>
<article>
<details>
<summary>{% trans 'Users you are blocking' %}</summary>
{% include 'users/relationship_list.html' with id="block" list=request.user.identity.blocking_identities.all %}
</details>
</article>
{% if allow_any_site %}
<article>
<details>

View file

@ -19,7 +19,7 @@
</header>
{% if form %}
<form action="{% url 'users:register' %}" method="post">
<small>{{ error }}</small>
<small>{{ error|default:"" }}</small>
<fieldset>
<label>
{% blocktrans %}Your username on {{ site_name }}{% endblocktrans %}