From 6a4bf7129dc6601277db41f1b52ffbc4de12b8a5 Mon Sep 17 00:00:00 2001 From: rubenwardy Date: Mon, 17 Jan 2022 15:06:03 +0000 Subject: [PATCH] Add user and package mentions --- app/blueprints/threads/__init__.py | 20 ++++++++ app/markdown.py | 75 ++++++++++++++++++++++++------ 2 files changed, 82 insertions(+), 13 deletions(-) diff --git a/app/blueprints/threads/__init__.py b/app/blueprints/threads/__init__.py index 92536f0..1767de3 100644 --- a/app/blueprints/threads/__init__.py +++ b/app/blueprints/threads/__init__.py @@ -16,6 +16,7 @@ from flask import * from flask_babel import gettext, lazy_gettext +from app.markdown import get_user_mentions, render_markdown from app.tasks.webhooktasks import post_discord_webhook bp = Blueprint("threads", __name__) @@ -238,6 +239,15 @@ def view(id): if not current_user in thread.watchers: thread.watchers.append(current_user) + for mentioned_username in get_user_mentions(render_markdown(comment)): + mentioned = User.query.filter_by(username=mentioned_username) + if mentioned is None: + continue + + msg = "Mentioned by {} in '{}'".format(current_user.display_name, thread.title) + addNotification(mentioned, current_user, NotificationType.THREAD_REPLY, + msg, thread.getViewURL(), thread.package) + msg = "New comment on '{}'".format(thread.title) addNotification(thread.watchers, current_user, NotificationType.THREAD_REPLY, msg, thread.getViewURL(), thread.package) @@ -335,6 +345,15 @@ def new(): if is_review_thread: package.review_thread = thread + for mentioned_username in get_user_mentions(render_markdown(form.comment.data)): + mentioned = User.query.filter_by(username=mentioned_username) + if mentioned is None: + continue + + msg = "Mentioned by {} in new thread '{}'".format(current_user.display_name, thread.title) + addNotification(mentioned, current_user, NotificationType.NEW_THREAD, + msg, thread.getViewURL(), thread.package) + notif_msg = "New thread '{}'".format(thread.title) if package is not None: addNotification(package.maintainers, current_user, NotificationType.NEW_THREAD, notif_msg, thread.getViewURL(), package) @@ -342,6 +361,7 @@ def new(): approvers = User.query.filter(User.rank >= UserRank.APPROVER).all() addNotification(approvers, current_user, NotificationType.EDITOR_MISC, notif_msg, thread.getViewURL(), package) + if is_review_thread: post_discord_webhook.delay(current_user.username, "Opened approval thread: {}".format(thread.getViewURL(absolute=True)), True) diff --git a/app/markdown.py b/app/markdown.py index 3997120..b240b99 100644 --- a/app/markdown.py +++ b/app/markdown.py @@ -5,10 +5,11 @@ from bleach import Cleaner from bleach.linkifier import LinkifyFilter from bs4 import BeautifulSoup from markdown import Markdown -from flask import Markup +from flask import Markup, url_for from markdown.extensions import Extension from markdown.inlinepatterns import SimpleTagInlineProcessor - +from markdown.inlinepatterns import Pattern +from xml.etree import ElementTree # Based on # https://github.com/Wenzil/mdx_bleach/blob/master/mdx_bleach/whitelist.py @@ -40,15 +41,17 @@ ALLOWED_CSS = [ "s2", "se", "sh", "si", "sx", "sr", "s1", "ss", "bp", "fm", "vc", "vg", "vi", "vm", "il", ] + def allow_class(_tag, name, value): return name == "class" and value in ALLOWED_CSS + ALLOWED_ATTRIBUTES = { "h1": ["id"], "h2": ["id"], "h3": ["id"], "h4": ["id"], - "a": ["href", "title"], + "a": ["href", "title", "data-username"], "img": ["src", "title", "alt"], "code": allow_class, "div": allow_class, @@ -64,23 +67,63 @@ def render_markdown(source): html = md.convert(source) cleaner = Cleaner( - tags=ALLOWED_TAGS, - attributes=ALLOWED_ATTRIBUTES, - protocols=ALLOWED_PROTOCOLS, - filters=[partial(LinkifyFilter, callbacks=bleach.linkifier.DEFAULT_CALLBACKS)]) + tags=ALLOWED_TAGS, + attributes=ALLOWED_ATTRIBUTES, + protocols=ALLOWED_PROTOCOLS, + filters=[partial(LinkifyFilter, callbacks=bleach.linkifier.DEFAULT_CALLBACKS)]) return cleaner.clean(html) class DelInsExtension(Extension): def extendMarkdown(self, md): - del_proc = SimpleTagInlineProcessor(r'(\~\~)(.+?)(\~\~)', 'del') - md.inlinePatterns.register(del_proc, 'del', 200) + del_proc = SimpleTagInlineProcessor(r"(\~\~)(.+?)(\~\~)", "del") + md.inlinePatterns.register(del_proc, "del", 200) - ins_proc = SimpleTagInlineProcessor(r'(\+\+)(.+?)(\+\+)', 'ins') - md.inlinePatterns.register(ins_proc, 'ins', 200) + ins_proc = SimpleTagInlineProcessor(r"(\+\+)(.+?)(\+\+)", "ins") + md.inlinePatterns.register(ins_proc, "ins", 200) -MARKDOWN_EXTENSIONS = ["fenced_code", "tables", "codehilite", "toc", DelInsExtension()] +RE_PARTS = dict( + USER=r"[A-Za-z0-9._-]*\b", + REPO=r"[A-Za-z0-9_]+\b" +) + + +class MentionPattern(Pattern): + ANCESTOR_EXCLUDES = ("a",) + + def __init__(self, config, md): + MENTION_RE = r"(@({USER})(?:\/({REPO}))?)".format(**RE_PARTS) + super(MentionPattern, self).__init__(MENTION_RE, md) + self.config = config + + def handleMatch(self, m): + label = m.group(2) + user = m.group(3) + package_name = m.group(4) + if package_name: + el = ElementTree.Element("a") + el.text = label + el.set("href", url_for("packages.view", author=user, name=package_name)) + return el + else: + el = ElementTree.Element("a") + el.text = label + el.set("href", url_for("users.profile", username=user)) + el.set("data-username", user) + return el + + +class MentionExtension(Extension): + def __init__(self, *args, **kwargs): + super(MentionExtension, self).__init__(*args, **kwargs) + + def extendMarkdown(self, md): + md.ESCAPED_CHARS.append("@") + md.inlinePatterns.register(MentionPattern(self.getConfigs(), md), "mention", 20) + + +MARKDOWN_EXTENSIONS = ["fenced_code", "tables", "codehilite", "toc", DelInsExtension(), MentionExtension()] MARKDOWN_EXTENSION_CONFIG = { "fenced_code": {}, "tables": {}, @@ -109,7 +152,7 @@ def get_headings(html: str): root = [] stack = [] for heading in headings: - this = { "link": heading.get("id") or "", "text": heading.text, "children": [] } + this = {"link": heading.get("id") or "", "text": heading.text, "children": []} this_level = int(heading.name[1:]) - 1 while this_level <= len(stack): @@ -123,3 +166,9 @@ def get_headings(html: str): stack.append(this) return root + + +def get_user_mentions(html: str) -> set: + soup = BeautifulSoup(html, "html.parser") + links = soup.select("a[data-username]") + return set([x.get("data-username") for x in links])