From a0fbf5e146eabca15d96f0a38c28e824d6d07a3a Mon Sep 17 00:00:00 2001 From: freamon Date: Fri, 10 Jan 2025 14:03:00 +0000 Subject: [PATCH] mentions in comments - use shared code for outbound federation / notification --- app/post/routes.py | 334 +------------------------------------- app/shared/tasks/notes.py | 51 +++--- 2 files changed, 28 insertions(+), 357 deletions(-) diff --git a/app/post/routes.py b/app/post/routes.py index 33588d32..f870b142 100644 --- a/app/post/routes.py +++ b/app/post/routes.py @@ -33,6 +33,7 @@ from app.utils import get_setting, render_template, allowlist_html, markdown_to_ recently_downvoted_posts, recently_upvoted_post_replies, recently_downvoted_post_replies, reply_is_stupid, \ languages_for_form, menu_topics, add_to_modlog, blocked_communities, piefed_markdown_to_lemmy_markdown, \ permission_required, blocked_users, get_request, is_local_image_url, is_video_url +from app.shared.reply import make_reply, edit_reply def show_post(post_id: int): @@ -70,103 +71,11 @@ def show_post(post_id: int): if current_user.is_authenticated and current_user.verified and form.validate_on_submit(): try: - reply = PostReply.new(current_user, post, in_reply_to=None, body=piefed_markdown_to_lemmy_markdown(form.body.data), - body_html=markdown_to_html(form.body.data), notify_author=form.notify_author.data, - language_id=form.language_id.data) + reply = make_reply(form, post, None, 1) except Exception as ex: flash(_('Your reply was not accepted because %(reason)s', reason=str(ex)), 'error') return redirect(url_for('activitypub.post_ap', post_id=post_id)) - current_user.language_id = form.language_id.data - reply.ap_id = reply.profile_id() - db.session.commit() - form.body.data = '' - flash('Your comment has been added.') - - # federation - reply_json = { - 'type': 'Note', - 'id': reply.public_url(), - 'attributedTo': current_user.public_url(), - 'to': [ - 'https://www.w3.org/ns/activitystreams#Public' - ], - 'cc': [ - community.public_url(), post.author.public_url() - ], - 'content': reply.body_html, - 'inReplyTo': post.profile_id(), - 'mediaType': 'text/html', - 'source': {'content': reply.body, 'mediaType': 'text/markdown'}, - 'published': ap_datetime(utcnow()), - 'distinguished': False, - 'audience': community.public_url(), - 'tag': [{ - 'href': post.author.public_url(), - 'name': post.author.mention_tag(), - 'type': 'Mention' - }], - 'language': { - 'identifier': reply.language_code(), - 'name': reply.language_name() - }, - 'contentMap': { - reply.language_code(): reply.body_html - } - } - create_json = { - 'type': 'Create', - 'actor': current_user.public_url(), - 'audience': community.public_url(), - 'to': [ - 'https://www.w3.org/ns/activitystreams#Public' - ], - 'cc': [ - community.public_url(), post.author.public_url() - ], - 'object': reply_json, - 'id': f"https://{current_app.config['SERVER_NAME']}/activities/create/{gibberish(15)}", - 'tag': [{ - 'href': post.author.public_url(), - 'name': post.author.mention_tag(), - 'type': 'Mention' - }] - } - if not community.is_local(): # this is a remote community, send it to the instance that hosts it - success = post_request_in_background(community.ap_inbox_url, create_json, current_user.private_key, - current_user.public_url() + '#main-key', timeout=10) - if success is False or isinstance(success, str): - flash('Failed to send to remote instance', 'error') - else: # local community - send it to followers on remote instances - announce = { - "id": f"https://{current_app.config['SERVER_NAME']}/activities/announce/{gibberish(15)}", - "type": 'Announce', - "to": [ - "https://www.w3.org/ns/activitystreams#Public" - ], - "actor": community.public_url(), - "cc": [ - community.ap_followers_url - ], - '@context': default_context(), - 'object': create_json - } - - for instance in community.following_instances(): - if instance.inbox and not current_user.has_blocked_instance(instance.id) and not instance_banned(instance.domain): - send_to_remote_instance(instance.id, community.id, announce) - - # send copy of Note to post author (who won't otherwise get it if no-one else on their instance is subscribed to the community) - if not post.author.is_local() and post.author.ap_domain != community.ap_domain: - if not community.is_local() or (community.is_local and not community.has_followers_from_domain(post.author.ap_domain)): - success = post_request_in_background(post.author.ap_inbox_url, create_json, current_user.private_key, - current_user.public_url() + '#main-key', timeout=10) - if success is False or isinstance(success, str): - # sending to shared inbox is good enough for Mastodon, but Lemmy will reject it the local community has no followers - personal_inbox = post.author.public_url() + '/inbox' - post_request_in_background(personal_inbox, create_json, current_user.private_key, - current_user.public_url() + '#main-key', timeout=10) - return redirect(url_for('activitypub.post_ap', post_id=post_id, _anchor=f'comment_{reply.id}')) else: replies = post_replies(post.id, sort) @@ -525,42 +434,11 @@ def add_reply(post_id: int, comment_id: int): form = NewReplyForm() form.language_id.choices = languages_for_form() if form.validate_on_submit(): - if reply_already_exists(user_id=current_user.id, post_id=post.id, parent_id=in_reply_to.id, body=form.body.data): - if in_reply_to.depth <= constants.THREAD_CUTOFF_DEPTH: - return redirect(url_for('activitypub.post_ap', post_id=post_id, _anchor=f'comment_{in_reply_to.id}')) - else: - return redirect(url_for('post.continue_discussion', post_id=post_id, comment_id=in_reply_to.parent_id)) - - if reply_is_just_link_to_gif_reaction(form.body.data): - current_user.reputation -= 1 - flash(_('This type of comment is not accepted, sorry.'), 'error') - if in_reply_to.depth <= constants.THREAD_CUTOFF_DEPTH: - return redirect(url_for('activitypub.post_ap', post_id=post_id, _anchor=f'comment_{in_reply_to.id}')) - else: - return redirect(url_for('post.continue_discussion', post_id=post_id, comment_id=in_reply_to.parent_id)) - - if reply_is_stupid(form.body.data): - existing_vote = PostReplyVote.query.filter_by(user_id=current_user.id, post_reply_id=in_reply_to.id).first() - if existing_vote is None: - flash(_('We have upvoted the comment for you.'), 'warning') - comment_vote(in_reply_to.id, 'upvote') - else: - flash(_('You have already upvoted the comment, you do not need to say "this" also.'), 'error') - if in_reply_to.depth <= constants.THREAD_CUTOFF_DEPTH: - return redirect(url_for('activitypub.post_ap', post_id=post_id)) - else: - return redirect(url_for('post.continue_discussion', post_id=post_id, comment_id=in_reply_to.parent_id)) - current_user.last_seen = utcnow() current_user.ip_address = ip_address() - current_user.language_id = form.language_id.data try: - reply = PostReply.new(current_user, post, in_reply_to, - body=piefed_markdown_to_lemmy_markdown(form.body.data), - body_html=markdown_to_html(form.body.data), - notify_author=form.notify_author.data, - language_id=form.language_id.data) + reply = make_reply(form, post, in_reply_to.id, 1) except Exception as ex: flash(_('Your reply was not accepted because %(reason)s', reason=str(ex)), 'error') if in_reply_to.depth <= constants.THREAD_CUTOFF_DEPTH: @@ -568,103 +446,6 @@ def add_reply(post_id: int, comment_id: int): else: return redirect(url_for('post.continue_discussion', post_id=post_id, comment_id=in_reply_to.parent_id)) - form.body.data = '' - flash('Your comment has been added.') - - # federation - if not post.community.local_only: - reply_json = { - 'type': 'Note', - 'id': reply.public_url(), - 'attributedTo': current_user.public_url(), - 'to': [ - 'https://www.w3.org/ns/activitystreams#Public' - ], - 'cc': [ - post.community.public_url(), - in_reply_to.author.public_url() - ], - 'content': reply.body_html, - 'inReplyTo': in_reply_to.profile_id(), - 'url': reply.profile_id(), - 'mediaType': 'text/html', - 'source': {'content': reply.body, 'mediaType': 'text/markdown'}, - 'published': ap_datetime(utcnow()), - 'distinguished': False, - 'audience': post.community.public_url(), - 'language': { - 'identifier': reply.language_code(), - 'name': reply.language_name() - }, - 'contentMap': { - 'en': reply.body_html - } - } - create_json = { - '@context': default_context(), - 'type': 'Create', - 'actor': current_user.public_url(), - 'audience': post.community.public_url(), - 'to': [ - 'https://www.w3.org/ns/activitystreams#Public' - ], - 'cc': [ - post.community.public_url(), - in_reply_to.author.public_url() - ], - 'object': reply_json, - 'id': f"https://{current_app.config['SERVER_NAME']}/activities/create/{gibberish(15)}" - } - if in_reply_to.notify_author and in_reply_to.author.ap_id is not None: - reply_json['tag'] = [ - { - 'href': in_reply_to.author.public_url(), - 'name': in_reply_to.author.mention_tag(), - 'type': 'Mention' - } - ] - create_json['tag'] = [ - { - 'href': in_reply_to.author.public_url(), - 'name': in_reply_to.author.mention_tag(), - 'type': 'Mention' - } - ] - if not post.community.is_local(): # this is a remote community, send it to the instance that hosts it - success = post_request(post.community.ap_inbox_url, create_json, current_user.private_key, - current_user.public_url() + '#main-key') - if success is False or isinstance(success, str): - flash('Failed to send reply', 'error') - else: # local community - send it to followers on remote instances - announce = { - "id": f"https://{current_app.config['SERVER_NAME']}/activities/announce/{gibberish(15)}", - "type": 'Announce', - "to": [ - "https://www.w3.org/ns/activitystreams#Public" - ], - "actor": post.community.public_url(), - "cc": [ - post.community.ap_followers_url - ], - '@context': default_context(), - 'object': create_json - } - - for instance in post.community.following_instances(): - if instance.inbox and not current_user.has_blocked_instance(instance.id) and not instance_banned(instance.domain): - send_to_remote_instance(instance.id, post.community.id, announce) - - # send copy of Note to comment author (who won't otherwise get it if no-one else on their instance is subscribed to the community) - if not in_reply_to.author.is_local() and in_reply_to.author.ap_domain != reply.community.ap_domain: - if not post.community.is_local() or (post.community.is_local and not post.community.has_followers_from_domain(in_reply_to.author.ap_domain)): - success = post_request(in_reply_to.author.ap_inbox_url, create_json, current_user.private_key, - current_user.public_url() + '#main-key') - if success is False or isinstance(success, str): - # sending to shared inbox is good enough for Mastodon, but Lemmy will reject it the local community has no followers - personal_inbox = in_reply_to.author.public_url() + '/inbox' - post_request(personal_inbox, create_json, current_user.private_key, - current_user.public_url() + '#main-key') - if reply.depth <= constants.THREAD_CUTOFF_DEPTH: return redirect(url_for('activitypub.post_ap', post_id=post_id, _anchor=f'comment_{reply.id}')) else: @@ -1521,114 +1302,7 @@ def post_reply_edit(post_id: int, comment_id: int): form.language_id.choices = languages_for_form() if post_reply.user_id == current_user.id or post.community.is_moderator(): if form.validate_on_submit(): - post_reply.body = piefed_markdown_to_lemmy_markdown(form.body.data) - post_reply.body_html = markdown_to_html(form.body.data) - post_reply.notify_author = form.notify_author.data - post.community.last_active = utcnow() - post_reply.edited_at = utcnow() - post_reply.language_id = form.language_id.data - db.session.commit() - flash(_('Your changes have been saved.'), 'success') - - if post_reply.parent_id: - in_reply_to = PostReply.query.get(post_reply.parent_id) - else: - in_reply_to = post - # federate edit - if not post.community.local_only: - reply_json = { - 'type': 'Note', - 'id': post_reply.public_url(), - 'attributedTo': current_user.public_url(), - 'to': [ - 'https://www.w3.org/ns/activitystreams#Public' - ], - 'cc': [ - post.community.public_url(), - in_reply_to.author.public_url() - ], - 'content': post_reply.body_html, - 'inReplyTo': in_reply_to.profile_id(), - 'url': post_reply.public_url(), - 'mediaType': 'text/html', - 'source': {'content': post_reply.body, 'mediaType': 'text/markdown'}, - 'published': ap_datetime(post_reply.posted_at), - 'updated': ap_datetime(post_reply.edited_at), - 'distinguished': False, - 'audience': post.community.public_url(), - 'contentMap': { - 'en': post_reply.body_html - }, - 'language': { - 'identifier': post_reply.language_code(), - 'name': post_reply.language_name() - } - } - update_json = { - '@context': default_context(), - 'type': 'Update', - 'actor': current_user.public_url(), - 'audience': post.community.public_url(), - 'to': [ - 'https://www.w3.org/ns/activitystreams#Public' - ], - 'cc': [ - post.community.public_url(), - in_reply_to.author.public_url() - ], - 'object': reply_json, - 'id': f"https://{current_app.config['SERVER_NAME']}/activities/update/{gibberish(15)}" - } - if in_reply_to.notify_author and in_reply_to.author.ap_id is not None: - reply_json['tag'] = [ - { - 'href': in_reply_to.author.public_url(), - 'name': in_reply_to.author.mention_tag(), - 'type': 'Mention' - } - ] - update_json['tag'] = [ - { - 'href': in_reply_to.author.public_url(), - 'name': in_reply_to.author.mention_tag(), - 'type': 'Mention' - } - ] - if not post.community.is_local(): # this is a remote community, send it to the instance that hosts it - success = post_request(post.community.ap_inbox_url, update_json, current_user.private_key, - current_user.public_url() + '#main-key') - if success is False or isinstance(success, str): - flash('Failed to send send edit to remote server', 'error') - else: # local community - send it to followers on remote instances - announce = { - "id": f"https://{current_app.config['SERVER_NAME']}/activities/announce/{gibberish(15)}", - "type": 'Announce', - "to": [ - "https://www.w3.org/ns/activitystreams#Public" - ], - "actor": post.community.public_url(), - "cc": [ - post.community.ap_followers_url - ], - '@context': default_context(), - 'object': update_json - } - - for instance in post.community.following_instances(): - if instance.inbox and not current_user.has_blocked_instance(instance.id) and not instance_banned(instance.domain): - send_to_remote_instance(instance.id, post.community.id, announce) - - # send copy of Note to post author (who won't otherwise get it if no-one else on their instance is subscribed to the community) - if not in_reply_to.author.is_local() and in_reply_to.author.ap_domain != post_reply.community.ap_domain: - if not post.community.is_local() or (post.community.is_local and not post.community.has_followers_from_domain(in_reply_to.author.ap_domain)): - success = post_request(in_reply_to.author.ap_inbox_url, update_json, current_user.private_key, - current_user.public_url() + '#main-key') - if success is False or isinstance(success, str): - # sending to shared inbox is good enough for Mastodon, but Lemmy will reject it the local community has no followers - personal_inbox = in_reply_to.author.public_url() + '/inbox' - post_request(personal_inbox, update_json, current_user.private_key, - current_user.public_url() + '#main-key') - + edit_reply(form, post_reply, post, 1) return redirect(url_for('activitypub.post_ap', post_id=post.id)) else: form.body.data = post_reply.body diff --git a/app/shared/tasks/notes.py b/app/shared/tasks/notes.py index 006c6cf0..5f424621 100644 --- a/app/shared/tasks/notes.py +++ b/app/shared/tasks/notes.py @@ -67,6 +67,7 @@ def send_reply(user_id, reply_id, parent_id, edit=False): parent = reply.post community = reply.community + # Find any users Mentioned in reply with @user@instance syntax recipients = [parent.author] pattern = r"@([a-zA-Z0-9_.-]*)@([a-zA-Z0-9_.-]*)\b" matches = re.finditer(pattern, reply.body) @@ -74,10 +75,11 @@ def send_reply(user_id, reply_id, parent_id, edit=False): recipient = None if match.group(2) == current_app.config['SERVER_NAME']: user_name = match.group(1) - try: - recipient = search_for_user(user_name) - except: - pass + if user_name != user.user_name: + try: + recipient = search_for_user(user_name) + except: + pass else: ap_id = f"{match.group(1)}@{match.group(2)}" try: @@ -94,18 +96,20 @@ def send_reply(user_id, reply_id, parent_id, edit=False): if add_recipient: recipients.append(recipient) - if community.local_only: - for recipient in recipients: - if recipient.is_local() and recipient.id != parent.author.id: - already_notified = cache.get(f'{recipient.id} notified of {reply.id}') - if not already_notified: - cache.set(f'{recipient.id} notified of {reply.id}', True, timeout=86400) - notification = Notification(user_id=recipient.id, title=_('You have been mentioned in a comment'), - url=f"https://{current_app.config['SERVER_NAME']}/comment/{reply.id}", - author_id=user.id) - recipient.unread_notifications += 1 - db.session.add(notification) - db.session.commit() + # Notify any local users that have been Mentioned + for recipient in recipients: + if recipient.is_local() and recipient.id != parent.author.id: + if edit: + existing_notification = Notification.query.filter(Notification.user_id == recipient.id, Notification.url == f"https://{current_app.config['SERVER_NAME']}/comment/{reply.id}").first() + else: + existing_notification = None + if not existing_notification: + notification = Notification(user_id=recipient.id, title=_(f"You have been mentioned in comment {reply.id}"), + url=f"https://{current_app.config['SERVER_NAME']}/comment/{reply.id}", + author_id=user.id) + recipient.unread_notifications += 1 + db.session.add(notification) + db.session.commit() if community.local_only or not community.instance.online(): return @@ -163,6 +167,7 @@ def send_reply(user_id, reply_id, parent_id, edit=False): domains_sent_to = [current_app.config['SERVER_NAME']] + # send the activity as an Announce if the community is local, or as a Create if not if community.is_local(): del create['@context'] @@ -186,20 +191,12 @@ def send_reply(user_id, reply_id, parent_id, edit=False): post_request(community.ap_inbox_url, create, user.private_key, user.public_url() + '#main-key') domains_sent_to.append(community.instance.domain) - # send copy to anyone else Mentioned in reply. (mostly for other local users and users on microblog sites) + # send copy of the Create to anyone else Mentioned in reply, but not on an instance that's already sent to. + if '@context' not in create: + create['@context'] = default_context() for recipient in recipients: if recipient.instance.domain not in domains_sent_to: post_request(recipient.instance.inbox, create, user.private_key, user.public_url() + '#main-key') - if recipient.is_local() and recipient.id != parent.author.id: - already_notified = cache.get(f'{recipient.id} notified of {reply.id}') - if not already_notified: - cache.set(f'{recipient.id} notified of {reply.id}', True, timeout=86400) - notification = Notification(user_id=recipient.id, title=_('You have been mentioned in a comment'), - url=f"https://{current_app.config['SERVER_NAME']}/comment/{reply.id}", - author_id=user.id) - recipient.unread_notifications += 1 - db.session.add(notification) - db.session.commit()