mentions in comments - use shared code for outbound federation / notification

This commit is contained in:
freamon 2025-01-10 14:03:00 +00:00
parent 385bd6bfbe
commit a0fbf5e146
2 changed files with 28 additions and 357 deletions

View file

@ -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, \ 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, \ 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 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): 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(): if current_user.is_authenticated and current_user.verified and form.validate_on_submit():
try: try:
reply = PostReply.new(current_user, post, in_reply_to=None, body=piefed_markdown_to_lemmy_markdown(form.body.data), reply = make_reply(form, post, None, 1)
body_html=markdown_to_html(form.body.data), notify_author=form.notify_author.data,
language_id=form.language_id.data)
except Exception as ex: except Exception as ex:
flash(_('Your reply was not accepted because %(reason)s', reason=str(ex)), 'error') flash(_('Your reply was not accepted because %(reason)s', reason=str(ex)), 'error')
return redirect(url_for('activitypub.post_ap', post_id=post_id)) 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}')) return redirect(url_for('activitypub.post_ap', post_id=post_id, _anchor=f'comment_{reply.id}'))
else: else:
replies = post_replies(post.id, sort) replies = post_replies(post.id, sort)
@ -525,42 +434,11 @@ def add_reply(post_id: int, comment_id: int):
form = NewReplyForm() form = NewReplyForm()
form.language_id.choices = languages_for_form() form.language_id.choices = languages_for_form()
if form.validate_on_submit(): 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.last_seen = utcnow()
current_user.ip_address = ip_address() current_user.ip_address = ip_address()
current_user.language_id = form.language_id.data
try: try:
reply = PostReply.new(current_user, post, in_reply_to, reply = make_reply(form, post, in_reply_to.id, 1)
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)
except Exception as ex: except Exception as ex:
flash(_('Your reply was not accepted because %(reason)s', reason=str(ex)), 'error') flash(_('Your reply was not accepted because %(reason)s', reason=str(ex)), 'error')
if in_reply_to.depth <= constants.THREAD_CUTOFF_DEPTH: if in_reply_to.depth <= constants.THREAD_CUTOFF_DEPTH:
@ -568,103 +446,6 @@ def add_reply(post_id: int, comment_id: int):
else: else:
return redirect(url_for('post.continue_discussion', post_id=post_id, comment_id=in_reply_to.parent_id)) 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: if reply.depth <= constants.THREAD_CUTOFF_DEPTH:
return redirect(url_for('activitypub.post_ap', post_id=post_id, _anchor=f'comment_{reply.id}')) return redirect(url_for('activitypub.post_ap', post_id=post_id, _anchor=f'comment_{reply.id}'))
else: else:
@ -1521,114 +1302,7 @@ def post_reply_edit(post_id: int, comment_id: int):
form.language_id.choices = languages_for_form() form.language_id.choices = languages_for_form()
if post_reply.user_id == current_user.id or post.community.is_moderator(): if post_reply.user_id == current_user.id or post.community.is_moderator():
if form.validate_on_submit(): if form.validate_on_submit():
post_reply.body = piefed_markdown_to_lemmy_markdown(form.body.data) edit_reply(form, post_reply, post, 1)
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')
return redirect(url_for('activitypub.post_ap', post_id=post.id)) return redirect(url_for('activitypub.post_ap', post_id=post.id))
else: else:
form.body.data = post_reply.body form.body.data = post_reply.body

View file

@ -67,6 +67,7 @@ def send_reply(user_id, reply_id, parent_id, edit=False):
parent = reply.post parent = reply.post
community = reply.community community = reply.community
# Find any users Mentioned in reply with @user@instance syntax
recipients = [parent.author] recipients = [parent.author]
pattern = r"@([a-zA-Z0-9_.-]*)@([a-zA-Z0-9_.-]*)\b" pattern = r"@([a-zA-Z0-9_.-]*)@([a-zA-Z0-9_.-]*)\b"
matches = re.finditer(pattern, reply.body) matches = re.finditer(pattern, reply.body)
@ -74,6 +75,7 @@ def send_reply(user_id, reply_id, parent_id, edit=False):
recipient = None recipient = None
if match.group(2) == current_app.config['SERVER_NAME']: if match.group(2) == current_app.config['SERVER_NAME']:
user_name = match.group(1) user_name = match.group(1)
if user_name != user.user_name:
try: try:
recipient = search_for_user(user_name) recipient = search_for_user(user_name)
except: except:
@ -94,13 +96,15 @@ def send_reply(user_id, reply_id, parent_id, edit=False):
if add_recipient: if add_recipient:
recipients.append(recipient) recipients.append(recipient)
if community.local_only: # Notify any local users that have been Mentioned
for recipient in recipients: for recipient in recipients:
if recipient.is_local() and recipient.id != parent.author.id: if recipient.is_local() and recipient.id != parent.author.id:
already_notified = cache.get(f'{recipient.id} notified of {reply.id}') if edit:
if not already_notified: existing_notification = Notification.query.filter(Notification.user_id == recipient.id, Notification.url == f"https://{current_app.config['SERVER_NAME']}/comment/{reply.id}").first()
cache.set(f'{recipient.id} notified of {reply.id}', True, timeout=86400) else:
notification = Notification(user_id=recipient.id, title=_('You have been mentioned in a comment'), 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}", url=f"https://{current_app.config['SERVER_NAME']}/comment/{reply.id}",
author_id=user.id) author_id=user.id)
recipient.unread_notifications += 1 recipient.unread_notifications += 1
@ -163,6 +167,7 @@ def send_reply(user_id, reply_id, parent_id, edit=False):
domains_sent_to = [current_app.config['SERVER_NAME']] 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(): if community.is_local():
del create['@context'] 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') post_request(community.ap_inbox_url, create, user.private_key, user.public_url() + '#main-key')
domains_sent_to.append(community.instance.domain) 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: for recipient in recipients:
if recipient.instance.domain not in domains_sent_to: if recipient.instance.domain not in domains_sent_to:
post_request(recipient.instance.inbox, create, user.private_key, user.public_url() + '#main-key') 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()