Skip to content
3 changes: 3 additions & 0 deletions CONTRIBUTORS.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,9 @@ In chronological order:
* Update Polish translation
* Redirect to comment after moderation

* Julien Moura @Guts
* Notify through web hooks

* fliiiix <l33t.name>
* Import disqus posts without Email
* Import disqus post without IP
Expand Down
70 changes: 70 additions & 0 deletions contrib/webhook_template_slack.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
{
"blocks": [
{
"type": "header",
"text": {
"type": "plain_text",
"text": ":speech_balloon: New comment posted",
"emoji": true
}
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*Author:* $AUTHOR_NAME $AUTHOR_EMAIL $AUTHOR_WEBSITE"
}
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*IP:* $COMMENT_IP_ADDRESS"
}
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*Comment:*\n$COMMENT_TEXT"
}
},
{
"type": "divider"
},
{
"type": "actions",
"elements": [
{
"type": "button",
"text": {
"type": "plain_text",
"emoji": true,
"text": ":eye-in-speech-bubble: View comment"
},
"url": "$COMMENT_URL_VIEW"
},
{
"type": "button",
"text": {
"type": "plain_text",
"emoji": true,
"text": ":white_check_mark: Approve"
},
"style": "primary",
"url": "$COMMENT_URL_ACTIVATE"
},
{
"type": "button",
"text": {
"type": "plain_text",
"emoji": true,
"text": ":wastebasket: Deny"
},
"style": "danger",
"url": "$COMMENT_URL_DELETE"
}
]
}
]
}
8 changes: 5 additions & 3 deletions isso/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@
from isso.utils import http, JSONRequest, JSONResponse, hash
from isso.views import comments

from isso.ext.notifications import Stdout, SMTP
from isso.ext.notifications import Stdout, SMTP, WebHook

LOG_FORMAT = "%(asctime)s:%(levelname)s: %(message)s"
logging.getLogger("werkzeug").setLevel(logging.WARN)
Expand Down Expand Up @@ -107,10 +107,12 @@ def __init__(self, conf):
subscribers = []
smtp_backend = False
for backend in conf.getlist("general", "notify"):
if backend == "stdout":
if backend.lower() == "stdout":
subscribers.append(Stdout(self))
elif backend in ("smtp", "SMTP"):
elif backend.lower() == "smtp":
smtp_backend = True
elif backend.lower() == "webhook":
subscribers.append(WebHook(self))
else:
logger.warning("unknown notification backend '%s'", backend)
if smtp_backend or conf.getboolean("general", "reply-notifications"):
Expand Down
191 changes: 189 additions & 2 deletions isso/ext/notifications.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,23 +9,30 @@
from _thread import start_new_thread
from email.message import EmailMessage
from email.utils import formatdate
from pathlib import Path
from string import Template
from urllib.parse import quote

import logging

logger = logging.getLogger("isso")

try:
import uwsgi
except ImportError:
uwsgi = None

from isso import local
from isso import dist, local
from isso.views.comments import isurl


def create_comment_action_url(uri, action, key):
return uri + "/" + action + "/" + key

from requests import HTTPError, Session
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please don't introduce another dependency needlessly, but I think I already commented on that.


# Globals
logger = logging.getLogger("isso")


class SMTPConnection(object):
def __init__(self, conf):
Expand Down Expand Up @@ -250,3 +257,183 @@ def _delete_comment(self, id):

def _activate_comment(self, thread, comment):
logger.info("comment %(id)s activated" % thread)


class WebHook(object):
Comment thread
Guts marked this conversation as resolved.
"""Notification handler for web hook.
Comment thread
Guts marked this conversation as resolved.

:param isso_instance: Isso application instance. Used to get moderation key.
:type isso_instance: object

:raises ValueError: if the provided URL is not valid
:raises FileExistsError: if the provided JSON template doesn't exist
:raises TypeError: if the provided template file is not a JSON
"""

def __init__(self, isso_instance: object):
"""Instanciate class."""
# store isso instance
self.isso_instance = isso_instance
# retrieve relevant configuration
self.public_endpoint = isso_instance.conf.get(
section="server", option="public-endpoint"
) or local("host")
webhook_conf_section = isso_instance.conf.section("webhook")
self.wh_url = webhook_conf_section.get("url")
self.wh_template = webhook_conf_section.get("template")

# check required settings
if not isurl(self.wh_url):
raise ValueError(
"Web hook requires a valid URL. "
Comment thread
Guts marked this conversation as resolved.
"The provided one is not correct: {}".format(self.wh_url)
)

# check optional template
if not len(self.wh_template):
self.wh_template = None
logger.debug("No template provided.")
Comment thread
Guts marked this conversation as resolved.
elif not Path(self.wh_template).is_file():
raise FileExistsError(
"Invalid web hook template path: {}".format(self.wh_template)
)
elif not Path(self.wh_template).suffix == ".json":
raise TypeError()(
"Template must be a JSON file: {}".format(self.wh_template)
Comment thread
Guts marked this conversation as resolved.
)
else:
self.wh_template = Path(self.wh_template)

def __iter__(self):

yield "comments.new:after-save", self.new_comment

def new_comment(self, thread: dict, comment: dict) -> bool:
Comment thread
Guts marked this conversation as resolved.
"""Triggered when a new comment is saved.

:param thread: comment thread
:type thread: dict
:param comment: comment object
:type comment: dict

:return: True if eveythring went fine. False if not.
Comment thread
Guts marked this conversation as resolved.
:rtype: bool
"""

try:
# get moderation URLs
Comment thread
Guts marked this conversation as resolved.
moderation_urls = self.moderation_urls(thread, comment)

if self.wh_template:
post_data = self.render_template(thread, comment, moderation_urls)
else:
post_data = {
"author_name": comment.get("author", "Anonymous"),
"author_email": comment.get("email"),
"author_website": comment.get("website"),
"comment_ip_address": comment.get("remote_addr"),
"comment_text": comment.get("text"),
"comment_url_activate": moderation_urls[0],
"comment_url_delete": moderation_urls[1],
"comment_url_view": moderation_urls[2],
}

self.send(post_data)
except Exception as err:
logger.error(err)
return False
Comment thread
Guts marked this conversation as resolved.

return True

def moderation_urls(self, thread: dict, comment: dict) -> tuple:
"""Helper to build comment related URLs (deletion, activation, etc.).

:param thread: comment thread
:type thread: dict
:param comment: comment object
:type comment: dict

:return: tuple of URS in alpha order (activate, admin, delete, view)
:rtype: tuple
"""
uri = "{}/id/{}".format(self.public_endpoint, comment.get("id"))
key = self.isso_instance.sign(comment.get("id"))

url_activate = "{}/activate/{}".format(uri, key)
url_delete = "{}/delete/{}".format(uri, key)
url_view = "{}#isso-{}".format(
local("origin") + thread.get("uri"), comment.get("id")
)

return url_activate, url_delete, url_view
Comment thread
Guts marked this conversation as resolved.

def render_template(
self, thread: dict, comment: dict, moderation_urls: tuple
) -> str:
"""Format comment information as webhook payload filling the specified template.

:param thread: isso thread
:type thread: dict
:param comment: isso comment
:type comment: dict
:param moderation_urls: comment moderation URLs
:type comment: tuple

:return: formatted message from template
:rtype: str
"""
# load template
with self.wh_template.open("r") as in_file:
tpl_json_data = json.load(in_file)
tpl_str = Template(json.dumps(tpl_json_data))
Comment thread
Guts marked this conversation as resolved.

# substitute
out_msg = tpl_str.substitute(
Comment thread
Guts marked this conversation as resolved.
AUTHOR_NAME=comment.get("author", "Anonymous"),
AUTHOR_EMAIL="<{}>".format(comment.get("email", "")),
AUTHOR_WEBSITE=comment.get("website", ""),
COMMENT_IP_ADDRESS=comment.get("remote_addr"),
COMMENT_TEXT=comment.get("text"),
COMMENT_URL_ACTIVATE=moderation_urls[0],
COMMENT_URL_DELETE=moderation_urls[1],
COMMENT_URL_VIEW=moderation_urls[2],
)

return out_msg

def send(self, structured_msg: str) -> bool:
"""Send the structured message as a notification to the class webhook URL.

:param str structured_msg: structured message to send

:rtype: bool
"""
# load the message to ensure encoding
msg_json = json.loads(structured_msg)

with Session() as requests_session:

# send requests
response = requests_session.post(
url=self.wh_url,
json=json.dumps(msg_json),
headers={
"Content-Type": "application/json",
"User-Agent": "Isso/{0} (+https://posativ.org/isso)".format(
dist.version
),
},
)

try:
response.raise_for_status()
logger.info("Web hook sent to %s" % self.wh_url)
Comment thread
Guts marked this conversation as resolved.
except HTTPError as err:
logger.error(
"Something went wrong during POST request to the web hook. Trace: %s"
Comment thread
Guts marked this conversation as resolved.
% err
)
return False

# if no error occurred
return True
Comment thread
Guts marked this conversation as resolved.
12 changes: 12 additions & 0 deletions isso/isso.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -271,3 +271,15 @@ base =

# Limit the number of elements to return for each thread.
limit = 100

[webhook]
# Isso can notify you on new comments via web hook.
# By default, it sends a POST data with new comment metadata: author, author email, author website, text, moderation URLs (activation, deletion, view).
Comment thread
Guts marked this conversation as resolved.
# It's also possible to add a JSON template (using Python string.Template) to customize the POST data. Useful to fit some tools abilities ike Slack, Matrix, Teams, etc.
Comment thread
Guts marked this conversation as resolved.
# An example for Slack with block builder is prodived in the contrib folder.

# webhook URL
url =

# path to the JSON template. Optional.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can users supply an absolute or relative path here? Please state so clearly.

template =
Loading
Loading