Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: add webhook notification capability #724

Draft
wants to merge 21 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .travis.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
language: python
cache: pip

matrix:
include:
- python: 3.5
Expand Down
3 changes: 3 additions & 0 deletions CONTRIBUTORS.txt
Original file line number Diff line number Diff line change
Expand Up @@ -117,5 +117,8 @@ In chronological order:
* Update Polish translation
* Redirect to comment after moderation

* Julien Moura @Guts
* Notify through web hooks

* [Your name or handle] <[email or website]>
* [Brief summary of your changes]
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"
}
]
}
]
}
10 changes: 6 additions & 4 deletions isso/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@
from isso.utils import http, JSONRequest, html, hash
from isso.views import comments

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

logging.getLogger('werkzeug').setLevel(logging.WARN)
logging.basicConfig(
Expand Down Expand Up @@ -106,12 +106,14 @@ 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(None))
elif backend in ("smtp", "SMTP"):
elif backend.lower() == "smtp":
smtp_backend = True
elif backend.lower() == "webhook":
subscribers.append(WebHook(self))
else:
logger.warn("unknown notification backend '%s'", backend)
logger.warn("Unknown notification backend '%s'", backend)
if smtp_backend or conf.getboolean("general", "reply-notifications"):
subscribers.append(SMTP(self))

Expand Down
2 changes: 1 addition & 1 deletion isso/dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from werkzeug.middleware.dispatcher import DispatcherMiddleware
from werkzeug.wrappers import Response

from isso import dist, make_app, wsgi, config
from isso import make_app, wsgi, config

logger = logging.getLogger("isso")

Expand Down
191 changes: 189 additions & 2 deletions isso/ext/notifications.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,20 +13,27 @@
from email.header import Header
from email.mime.text import MIMEText

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

from _thread import start_new_thread

from requests import HTTPError, Session
Copy link
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):

Expand Down Expand Up @@ -224,3 +231,183 @@ def _delete_comment(self, id):

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


class WebHook(object):
Guts marked this conversation as resolved.
Show resolved Hide resolved
"""Notification handler for web hook.
Guts marked this conversation as resolved.
Show resolved Hide 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. "
Guts marked this conversation as resolved.
Show resolved Hide 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.")
Guts marked this conversation as resolved.
Show resolved Hide 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)
Guts marked this conversation as resolved.
Show resolved Hide 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:
Guts marked this conversation as resolved.
Show resolved Hide 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.
Guts marked this conversation as resolved.
Show resolved Hide resolved
:rtype: bool
"""

try:
# get moderation URLs
Guts marked this conversation as resolved.
Show resolved Hide 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
Guts marked this conversation as resolved.
Show resolved Hide 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
Guts marked this conversation as resolved.
Show resolved Hide 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))
Guts marked this conversation as resolved.
Show resolved Hide resolved

# substitute
out_msg = tpl_str.substitute(
Guts marked this conversation as resolved.
Show resolved Hide 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)
Guts marked this conversation as resolved.
Show resolved Hide resolved
except HTTPError as err:
logger.error(
"Something went wrong during POST request to the web hook. Trace: %s"
Guts marked this conversation as resolved.
Show resolved Hide resolved
% err
)
return False

# if no error occurred
return True
Guts marked this conversation as resolved.
Show resolved Hide resolved
3 changes: 1 addition & 2 deletions isso/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,7 @@
import os
import pkg_resources

from isso import make_app
from isso import dist, config
from isso import config, make_app

application = make_app(
config.load(
Expand Down
2 changes: 1 addition & 1 deletion isso/tests/test_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from werkzeug.test import Client
from werkzeug.wrappers import Response

from isso import Isso, config, core, dist
from isso import Isso, config, core
from isso.utils import http

from fixtures import curl, FakeIP
Expand Down
Loading