Skip to content

Commit f38c1c0

Browse files
committed
Add WebHook notification ability
Add webhook Fix string formatting Fix string formatting Make it compatible with Python 3.5 ?! Add default webhook section Clean up and docstrings Handle HTTP errors Add webhook template for Slack Add requests as dependency Remove unused var Complete post data without template Enable cache
1 parent 4acc0e2 commit f38c1c0

7 files changed

Lines changed: 290 additions & 8 deletions

File tree

.travis.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
language: python
2+
cache: pip
3+
24
matrix:
35
include:
46
- python: 3.5
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
{
2+
"blocks": [
3+
{
4+
"type": "header",
5+
"text": {
6+
"type": "plain_text",
7+
"text": ":speech_balloon: New comment posted",
8+
"emoji": true
9+
}
10+
},
11+
{
12+
"type": "section",
13+
"text": {
14+
"type": "mrkdwn",
15+
"text": "*Author:* $AUTHOR_NAME $AUTHOR_EMAIL $AUTHOR_WEBSITE"
16+
}
17+
},
18+
{
19+
"type": "section",
20+
"text": {
21+
"type": "mrkdwn",
22+
"text": "*IP:* $COMMENT_IP_ADDRESS"
23+
}
24+
},
25+
{
26+
"type": "section",
27+
"text": {
28+
"type": "mrkdwn",
29+
"text": "*Comment:*\n$COMMENT_TEXT"
30+
}
31+
},
32+
{
33+
"type": "divider"
34+
},
35+
{
36+
"type": "actions",
37+
"elements": [
38+
{
39+
"type": "button",
40+
"text": {
41+
"type": "plain_text",
42+
"emoji": true,
43+
"text": ":eye-in-speech-bubble: View comment"
44+
},
45+
"url": "$COMMENT_URL_VIEW"
46+
},
47+
{
48+
"type": "button",
49+
"text": {
50+
"type": "plain_text",
51+
"emoji": true,
52+
"text": ":white_check_mark: Approve"
53+
},
54+
"style": "primary",
55+
"url": "$COMMENT_URL_ACTIVATE"
56+
},
57+
{
58+
"type": "button",
59+
"text": {
60+
"type": "plain_text",
61+
"emoji": true,
62+
"text": ":wastebasket: Deny"
63+
},
64+
"style": "danger",
65+
"url": "$COMMENT_URL_DELETE"
66+
}
67+
]
68+
}
69+
]
70+
}

isso/__init__.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@
7272
from isso.utils import http, JSONRequest, html, hash
7373
from isso.views import comments
7474

75-
from isso.ext.notifications import Stdout, SMTP
75+
from isso.ext.notifications import Stdout, SMTP, WebHook
7676

7777
logging.getLogger('werkzeug').setLevel(logging.WARN)
7878
logging.basicConfig(
@@ -106,12 +106,14 @@ def __init__(self, conf):
106106
subscribers = []
107107
smtp_backend = False
108108
for backend in conf.getlist("general", "notify"):
109-
if backend == "stdout":
109+
if backend.lower() == "stdout":
110110
subscribers.append(Stdout(None))
111-
elif backend in ("smtp", "SMTP"):
111+
elif backend.lower() == "smtp":
112112
smtp_backend = True
113+
elif backend.lower() == "webhook":
114+
subscribers.append(WebHook(self))
113115
else:
114-
logger.warn("unknown notification backend '%s'", backend)
116+
logger.warn("Unknown notification backend '%s'", backend)
115117
if smtp_backend or conf.getboolean("general", "reply-notifications"):
116118
subscribers.append(SMTP(self))
117119

isso/ext/notifications.py

Lines changed: 189 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,20 +13,27 @@
1313
from email.header import Header
1414
from email.mime.text import MIMEText
1515

16+
from pathlib import Path
17+
from string import Template
1618
from urllib.parse import quote
1719

1820
import logging
19-
logger = logging.getLogger("isso")
2021

2122
try:
2223
import uwsgi
2324
except ImportError:
2425
uwsgi = None
2526

26-
from isso import local
27+
from isso import dist, local
28+
from isso.views.comments import isurl
2729

2830
from _thread import start_new_thread
2931

32+
from requests import HTTPError, Session
33+
34+
# Globals
35+
logger = logging.getLogger("isso")
36+
3037

3138
class SMTPConnection(object):
3239

@@ -224,3 +231,183 @@ def _delete_comment(self, id):
224231

225232
def _activate_comment(self, thread, comment):
226233
logger.info("comment %(id)s activated" % thread)
234+
235+
236+
class WebHook(object):
237+
"""Notification handler for web hook.
238+
239+
:param isso_instance: Isso application instance. Used to get moderation key.
240+
:type isso_instance: object
241+
242+
:raises ValueError: if the provided URL is not valid
243+
:raises FileExistsError: if the provided JSON template doesn't exist
244+
:raises TypeError: if the provided template file is not a JSON
245+
"""
246+
247+
def __init__(self, isso_instance: object):
248+
"""Instanciate class."""
249+
# store isso instance
250+
self.isso_instance = isso_instance
251+
# retrieve relevant configuration
252+
self.public_endpoint = isso_instance.conf.get(
253+
section="server", option="public-endpoint"
254+
) or local("host")
255+
webhook_conf_section = isso_instance.conf.section("webhook")
256+
self.wh_url = webhook_conf_section.get("url")
257+
self.wh_template = webhook_conf_section.get("template")
258+
259+
# check required settings
260+
if not isurl(self.wh_url):
261+
raise ValueError(
262+
"Web hook requires a valid URL. "
263+
"The provided one is not correct: {}".format(self.wh_url)
264+
)
265+
266+
# check optional template
267+
if not len(self.wh_template):
268+
self.wh_template = None
269+
logger.debug("No template provided.")
270+
elif not Path(self.wh_template).is_file():
271+
raise FileExistsError(
272+
"Invalid web hook template path: {}".format(self.wh_template)
273+
)
274+
elif not Path(self.wh_template).suffix == ".json":
275+
raise TypeError()(
276+
"Template must be a JSON file: {}".format(self.wh_template)
277+
)
278+
else:
279+
self.wh_template = Path(self.wh_template)
280+
281+
def __iter__(self):
282+
283+
yield "comments.new:after-save", self.new_comment
284+
285+
def new_comment(self, thread: dict, comment: dict) -> bool:
286+
"""Triggered when a new comment is saved.
287+
288+
:param thread: comment thread
289+
:type thread: dict
290+
:param comment: comment object
291+
:type comment: dict
292+
293+
:return: True if eveythring went fine. False if not.
294+
:rtype: bool
295+
"""
296+
297+
try:
298+
# get moderation URLs
299+
moderation_urls = self.moderation_urls(thread, comment)
300+
301+
if self.wh_template:
302+
post_data = self.render_template(thread, comment, moderation_urls)
303+
else:
304+
post_data = {
305+
"author_name": comment.get("author", "Anonymous"),
306+
"author_email": comment.get("email"),
307+
"author_website": comment.get("website"),
308+
"comment_ip_address": comment.get("remote_addr"),
309+
"comment_text": comment.get("text"),
310+
"comment_url_activate": moderation_urls[0],
311+
"comment_url_delete": moderation_urls[1],
312+
"comment_url_view": moderation_urls[2],
313+
}
314+
315+
self.send(post_data)
316+
except Exception as err:
317+
logger.error(err)
318+
return False
319+
320+
return True
321+
322+
def moderation_urls(self, thread: dict, comment: dict) -> tuple:
323+
"""Helper to build comment related URLs (deletion, activation, etc.).
324+
325+
:param thread: comment thread
326+
:type thread: dict
327+
:param comment: comment object
328+
:type comment: dict
329+
330+
:return: tuple of URS in alpha order (activate, admin, delete, view)
331+
:rtype: tuple
332+
"""
333+
uri = "{}/id/{}".format(self.public_endpoint, comment.get("id"))
334+
key = self.isso_instance.sign(comment.get("id"))
335+
336+
url_activate = "{}/activate/{}".format(uri, key)
337+
url_delete = "{}/delete/{}".format(uri, key)
338+
url_view = "{}#isso-{}".format(
339+
local("origin") + thread.get("uri"), comment.get("id")
340+
)
341+
342+
return url_activate, url_delete, url_view
343+
344+
def render_template(
345+
self, thread: dict, comment: dict, moderation_urls: tuple
346+
) -> str:
347+
"""Format comment information as webhook payload filling the specified template.
348+
349+
:param thread: isso thread
350+
:type thread: dict
351+
:param comment: isso comment
352+
:type comment: dict
353+
:param moderation_urls: comment moderation URLs
354+
:type comment: tuple
355+
356+
:return: formatted message from template
357+
:rtype: str
358+
"""
359+
# load template
360+
with self.wh_template.open("r") as in_file:
361+
tpl_json_data = json.load(in_file)
362+
tpl_str = Template(json.dumps(tpl_json_data))
363+
364+
# substitute
365+
out_msg = tpl_str.substitute(
366+
AUTHOR_NAME=comment.get("author", "Anonymous"),
367+
AUTHOR_EMAIL="<{}>".format(comment.get("email", "")),
368+
AUTHOR_WEBSITE=comment.get("website", ""),
369+
COMMENT_IP_ADDRESS=comment.get("remote_addr"),
370+
COMMENT_TEXT=comment.get("text"),
371+
COMMENT_URL_ACTIVATE=moderation_urls[0],
372+
COMMENT_URL_DELETE=moderation_urls[1],
373+
COMMENT_URL_VIEW=moderation_urls[2],
374+
)
375+
376+
return out_msg
377+
378+
def send(self, structured_msg: str) -> bool:
379+
"""Send the structured message as a notification to the class webhook URL.
380+
381+
:param str structured_msg: structured message to send
382+
383+
:rtype: bool
384+
"""
385+
# load the message to ensure encoding
386+
msg_json = json.loads(structured_msg)
387+
388+
with Session() as requests_session:
389+
390+
# send requests
391+
response = requests_session.post(
392+
url=self.wh_url,
393+
json=json.dumps(msg_json),
394+
headers={
395+
"Content-Type": "application/json",
396+
"User-Agent": "Isso/{0} (+https://posativ.org/isso)".format(
397+
dist.version
398+
),
399+
},
400+
)
401+
402+
try:
403+
response.raise_for_status()
404+
logger.info("Web hook sent to %s" % self.wh_url)
405+
except HTTPError as err:
406+
logger.error(
407+
"Something went wrong during POST request to the web hook. Trace: %s"
408+
% err
409+
)
410+
return False
411+
412+
# if no error occurred
413+
return True

setup.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,16 @@
55

66
from setuptools import setup, find_packages
77

8-
requires = ['itsdangerous', 'Jinja2', 'misaka>=2.0,<3.0', 'html5lib',
9-
'werkzeug>=1.0', 'bleach', 'flask-caching>=1.9']
8+
requires = [
9+
"bleach",
10+
"flask-caching>=1.9",
11+
"html5lib",
12+
"itsdangerous",
13+
"Jinja2",
14+
"misaka>=2.0,<3.0",
15+
"requests>=2.25,<2.26",
16+
"werkzeug>=1.0",
17+
]
1018

1119
if sys.version_info < (3, ):
1220
raise SystemExit("Python 2 is not supported.")

share/isso.conf

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,3 +249,15 @@ base =
249249

250250
# Limit the number of elements to return for each thread.
251251
limit = 100
252+
253+
[webhook]
254+
# Isso can notify you on new comments via web hook.
255+
# By default, it sends a POST data with new comment metadata: author, author email, author website, text, moderation URLs (activation, deletion, view).
256+
# 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.
257+
# An example for Slack with block builder is prodived in the contrib folder.
258+
259+
# webhook URL
260+
url =
261+
262+
# path to the JSON template. Optional.
263+
template =

tox.ini

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,5 @@ deps=
1818
Jinja2
1919
misaka>=2.0,<3.0
2020
passlib==1.5.3
21+
requests>=2.25,<2.26
2122
werkzeug>=1.0

0 commit comments

Comments
 (0)