From 8ad2796d2c9729bb8f613b5d31ee885ff038d3aa Mon Sep 17 00:00:00 2001 From: GeoJulien Date: Thu, 29 Apr 2021 23:33:11 +0200 Subject: [PATCH 01/20] Add webhook --- isso/__init__.py | 10 ++-- isso/ext/notifications.py | 111 +++++++++++++++++++++++++++++++++++++- 2 files changed, 116 insertions(+), 5 deletions(-) diff --git a/isso/__init__.py b/isso/__init__.py index 4cc3fdd40..9f6dcec01 100644 --- a/isso/__init__.py +++ b/isso/__init__.py @@ -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( @@ -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)) diff --git a/isso/ext/notifications.py b/isso/ext/notifications.py index 4b23525d0..1e35bdfae 100644 --- a/isso/ext/notifications.py +++ b/isso/ext/notifications.py @@ -13,10 +13,11 @@ 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 @@ -24,10 +25,16 @@ uwsgi = None from isso import local +from isso.utils import http +from isso.views.comments import isurl from _thread import start_new_thread +# Globals +logger = logging.getLogger("isso") + + class SMTPConnection(object): def __init__(self, conf): @@ -224,3 +231,105 @@ def _delete_comment(self, id): def _activate_comment(self, thread, comment): logger.info("comment %(id)s activated" % thread) + + +class WebHook(object): + def __init__(self, isso_instance: object): + # 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( + f"Web hook requires a valid URL. " + "The provided one is not correct: {self.wh_url}" + ) + + # check optional template + if not len(self.wh_template): + self.wh_template = None + logger.debug("No template provided.") + elif not Path(self.wh_template).is_file(): + raise FileExistsError(f"Invalid web hook template path: {self.wh_template}") + 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): + + if self.wh_template: + post_data = self.format(thread, comment) + else: + post_data = { + "author": comment.get("author", "Anonymous"), + "author_email": comment.get("email"), + "text": comment.get("text"), + } + print(post_data) + + self.send(post_data) + + def comment_urls(self, thread: dict, comment: dict) -> 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 + + def format( + self, + thread: dict, + comment: dict, + ) -> 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)) + print(type(tpl_str)) + + # build URLs + comment_urls = self.comment_urls(thread, comment) + + # substitute + out_msg = tpl_str.substitute( + 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=comment_urls[0], + COMMENT_URL_DELETE=comment_urls[1], + COMMENT_URL_VIEW=comment_urls[2], + ) + + return out_msg + + def send(self, structured_msg: dict) -> bool: + """Send the structured message as a notification to the class webhook URL. + + :param dict structured_msg: structured message to send + + :rtype: bool + """ + with http.curl("POST", self.wh_url, "/") as resp: + if resp: # may be None if request failed + return resp.status + + # if no error occurred + return True From 6317ad77f962a69f143c4cba14deb59fd1f8ef1e Mon Sep 17 00:00:00 2001 From: GeoJulien Date: Fri, 30 Apr 2021 13:15:45 +0200 Subject: [PATCH 02/20] Exclude .venv from flake8 --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index a6e690270..53a901298 100644 --- a/setup.cfg +++ b/setup.cfg @@ -6,4 +6,4 @@ universal=1 [flake8] ignore = E501, E402 -exclude = docs/conf.py,node_modules,.tox,.eggs,.git +exclude = docs/conf.py,node_modules,.tox,.eggs,.git,.venv From c3ae47522ca73651827107a6db09d6e510d49846 Mon Sep 17 00:00:00 2001 From: GeoJulien Date: Fri, 30 Apr 2021 13:17:39 +0200 Subject: [PATCH 03/20] Fix string formatting --- isso/ext/notifications.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/isso/ext/notifications.py b/isso/ext/notifications.py index 1e35bdfae..74a12f43c 100644 --- a/isso/ext/notifications.py +++ b/isso/ext/notifications.py @@ -248,8 +248,8 @@ def __init__(self, isso_instance: object): # check required settings if not isurl(self.wh_url): raise ValueError( - f"Web hook requires a valid URL. " - "The provided one is not correct: {self.wh_url}" + "Web hook requires a valid URL. " + f"The provided one is not correct: {self.wh_url}" ) # check optional template From 1a8f1464083ff22361484bed7e914a4d516cf4cc Mon Sep 17 00:00:00 2001 From: GeoJulien Date: Fri, 30 Apr 2021 13:18:14 +0200 Subject: [PATCH 04/20] Fix string formatting --- isso/ext/notifications.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/isso/ext/notifications.py b/isso/ext/notifications.py index 74a12f43c..eb9ce08e2 100644 --- a/isso/ext/notifications.py +++ b/isso/ext/notifications.py @@ -309,7 +309,7 @@ def format( # substitute out_msg = tpl_str.substitute( AUTHOR_NAME=comment.get("author", "Anonymous"), - AUTHOR_EMAIL="<>".format(comment.get("email")), + AUTHOR_EMAIL="<{}>".format(comment.get("email")), AUTHOR_WEBSITE=comment.get("website"), COMMENT_IP_ADDRESS=comment.get("remote_addr"), COMMENT_TEXT=comment.get("text"), From 107860cdd3deae1061e80e11b42153823046aea5 Mon Sep 17 00:00:00 2001 From: GeoJulien Date: Fri, 30 Apr 2021 13:21:25 +0200 Subject: [PATCH 05/20] Fix flake8 --- isso/dispatch.py | 2 +- isso/run.py | 3 +-- isso/tests/test_guard.py | 2 +- isso/tests/test_vote.py | 41 +++++++++++++++++++++------------------- 4 files changed, 25 insertions(+), 23 deletions(-) diff --git a/isso/dispatch.py b/isso/dispatch.py index 6b2867e7d..366eb1d08 100644 --- a/isso/dispatch.py +++ b/isso/dispatch.py @@ -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") diff --git a/isso/run.py b/isso/run.py index 02299c4fb..4f36b9a52 100644 --- a/isso/run.py +++ b/isso/run.py @@ -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( diff --git a/isso/tests/test_guard.py b/isso/tests/test_guard.py index b71c2390d..673219690 100644 --- a/isso/tests/test_guard.py +++ b/isso/tests/test_guard.py @@ -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 diff --git a/isso/tests/test_vote.py b/isso/tests/test_vote.py index f034c48dd..b074cc6c3 100644 --- a/isso/tests/test_vote.py +++ b/isso/tests/test_vote.py @@ -1,7 +1,5 @@ - from __future__ import unicode_literals -import os import json import tempfile import pkg_resources @@ -9,21 +7,21 @@ from werkzeug.wrappers import Response -from isso import Isso, core, config, dist +from isso import Isso, core, config from isso.utils import http from fixtures import curl, loads, FakeIP, JSONClient + http.curl = curl class TestVote(unittest.TestCase): - def setUp(self): self.path = tempfile.NamedTemporaryFile().name def makeClient(self, ip): - conf = config.load(pkg_resources.resource_filename('isso', 'defaults.ini')) + conf = config.load(pkg_resources.resource_filename("isso", "defaults.ini")) conf.set("general", "dbpath", self.path) conf.set("guard", "enabled", "off") conf.set("hash", "algorithm", "none") @@ -39,14 +37,16 @@ class App(Isso, core.Mixin): def testZeroLikes(self): rv = self.makeClient("127.0.0.1").post( - "/new?uri=test", data=json.dumps({"text": "..."})) - self.assertEqual(loads(rv.data)['likes'], 0) - self.assertEqual(loads(rv.data)['dislikes'], 0) + "/new?uri=test", data=json.dumps({"text": "..."}) + ) + self.assertEqual(loads(rv.data)["likes"], 0) + self.assertEqual(loads(rv.data)["dislikes"], 0) def testSingleLike(self): self.makeClient("127.0.0.1").post( - "/new?uri=test", data=json.dumps({"text": "..."})) + "/new?uri=test", data=json.dumps({"text": "..."}) + ) rv = self.makeClient("0.0.0.0").post("/id/1/like") self.assertEqual(rv.status_code, 200) @@ -56,7 +56,7 @@ def testSelfLike(self): bob = self.makeClient("127.0.0.1") bob.post("/new?uri=test", data=json.dumps({"text": "..."})) - rv = bob.post('/id/1/like') + rv = bob.post("/id/1/like") self.assertEqual(rv.status_code, 200) self.assertEqual(loads(rv.data)["likes"], 0) @@ -64,23 +64,25 @@ def testSelfLike(self): def testMultipleLikes(self): self.makeClient("127.0.0.1").post( - "/new?uri=test", data=json.dumps({"text": "..."})) + "/new?uri=test", data=json.dumps({"text": "..."}) + ) for num in range(15): - rv = self.makeClient("1.2.%i.0" % num).post('/id/1/like') + rv = self.makeClient("1.2.%i.0" % num).post("/id/1/like") self.assertEqual(rv.status_code, 200) self.assertEqual(loads(rv.data)["likes"], num + 1) def testVoteOnNonexistentComment(self): - rv = self.makeClient("1.2.3.4").post('/id/1/like') + rv = self.makeClient("1.2.3.4").post("/id/1/like") self.assertEqual(rv.status_code, 200) self.assertEqual(loads(rv.data), None) def testTooManyLikes(self): self.makeClient("127.0.0.1").post( - "/new?uri=test", data=json.dumps({"text": "..."})) + "/new?uri=test", data=json.dumps({"text": "..."}) + ) for num in range(256): - rv = self.makeClient("1.2.%i.0" % num).post('/id/1/like') + rv = self.makeClient("1.2.%i.0" % num).post("/id/1/like") self.assertEqual(rv.status_code, 200) if num >= 142: @@ -90,9 +92,10 @@ def testTooManyLikes(self): def testDislike(self): self.makeClient("127.0.0.1").post( - "/new?uri=test", data=json.dumps({"text": "..."})) - rv = self.makeClient("1.2.3.4").post('/id/1/dislike') + "/new?uri=test", data=json.dumps({"text": "..."}) + ) + rv = self.makeClient("1.2.3.4").post("/id/1/dislike") self.assertEqual(rv.status_code, 200) - self.assertEqual(loads(rv.data)['likes'], 0) - self.assertEqual(loads(rv.data)['dislikes'], 1) + self.assertEqual(loads(rv.data)["likes"], 0) + self.assertEqual(loads(rv.data)["dislikes"], 1) From c791ad4eee4daf0006d38c920359a9ef5dcb11a1 Mon Sep 17 00:00:00 2001 From: GeoJulien Date: Fri, 30 Apr 2021 13:24:48 +0200 Subject: [PATCH 06/20] Make it compatible with Python 3.5 ?! --- isso/ext/notifications.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/isso/ext/notifications.py b/isso/ext/notifications.py index eb9ce08e2..a9ef5e036 100644 --- a/isso/ext/notifications.py +++ b/isso/ext/notifications.py @@ -249,7 +249,7 @@ def __init__(self, isso_instance: object): if not isurl(self.wh_url): raise ValueError( "Web hook requires a valid URL. " - f"The provided one is not correct: {self.wh_url}" + "The provided one is not correct: {}".format(self.wh_url) ) # check optional template @@ -257,7 +257,7 @@ def __init__(self, isso_instance: object): self.wh_template = None logger.debug("No template provided.") elif not Path(self.wh_template).is_file(): - raise FileExistsError(f"Invalid web hook template path: {self.wh_template}") + raise FileExistsError("Invalid web hook template path: {}".format(self.wh_template)) else: self.wh_template = Path(self.wh_template) From 2dd097ae7342b920b7d81931090dc4c52f9de345 Mon Sep 17 00:00:00 2001 From: GeoJulien Date: Fri, 30 Apr 2021 13:35:00 +0200 Subject: [PATCH 07/20] Add myself --- CONTRIBUTORS.txt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CONTRIBUTORS.txt b/CONTRIBUTORS.txt index db1602e55..b195bc816 100644 --- a/CONTRIBUTORS.txt +++ b/CONTRIBUTORS.txt @@ -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] From e60e83b0465b71539855578334be3d40773c5b7b Mon Sep 17 00:00:00 2001 From: GeoJulien Date: Fri, 30 Apr 2021 14:21:23 +0200 Subject: [PATCH 08/20] Add default webhook section --- share/isso.conf | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/share/isso.conf b/share/isso.conf index 1dc8a0323..3607396a2 100644 --- a/share/isso.conf +++ b/share/isso.conf @@ -249,3 +249,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). +# 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. +# An example for Slack with block builder is prodived in the contrib folder. + +# webhook URL +url = + +# path to the JSON template. Optional. +template = From e3d06419e7e336f08e0c7b86dd983889811424d4 Mon Sep 17 00:00:00 2001 From: GeoJulien Date: Fri, 30 Apr 2021 14:48:35 +0200 Subject: [PATCH 09/20] Clean up and docstrings --- isso/ext/notifications.py | 120 ++++++++++++++++++++++++++++---------- 1 file changed, 89 insertions(+), 31 deletions(-) diff --git a/isso/ext/notifications.py b/isso/ext/notifications.py index a9ef5e036..4f7a9d9e9 100644 --- a/isso/ext/notifications.py +++ b/isso/ext/notifications.py @@ -24,12 +24,12 @@ except ImportError: uwsgi = None -from isso import local -from isso.utils import http +from isso import dist, local from isso.views.comments import isurl from _thread import start_new_thread +from requests import Session # Globals logger = logging.getLogger("isso") @@ -234,7 +234,18 @@ def _activate_comment(self, thread, comment): class WebHook(object): + """Notification handler for web hook. + + :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 @@ -257,30 +268,57 @@ def __init__(self, isso_instance: object): self.wh_template = None logger.debug("No template provided.") elif not Path(self.wh_template).is_file(): - raise FileExistsError("Invalid web hook template path: {}".format(self.wh_template)) + 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) + ) 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): + yield "comments.new:after-save", self.new_comment - if self.wh_template: - post_data = self.format(thread, comment) - else: - post_data = { - "author": comment.get("author", "Anonymous"), - "author_email": comment.get("email"), - "text": comment.get("text"), - } - print(post_data) + def new_comment(self, thread: dict, comment: dict): + """Triggered when a new comment is saved. - self.send(post_data) + :param thread: comment thread + :type thread: dict + :param comment: comment object + :type comment: dict + """ - def comment_urls(self, thread: dict, comment: dict) -> tuple: + try: + # get moderation URLs + moderation_urls = self.moderation_urls(thread, comment) + if self.wh_template: + post_data = self.render_template(thread, comment, moderation_urls) + else: + post_data = { + "author": comment.get("author", "Anonymous"), + "author_email": comment.get("email"), + "text": comment.get("text"), + } + + self.send(post_data) + except Exception as err: + logger.error(err) + + 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")) @@ -292,11 +330,21 @@ def comment_urls(self, thread: dict, comment: dict) -> tuple: return url_activate, url_delete, url_view - def format( - self, - thread: dict, - comment: dict, + 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) @@ -309,27 +357,37 @@ def format( # substitute out_msg = tpl_str.substitute( AUTHOR_NAME=comment.get("author", "Anonymous"), - AUTHOR_EMAIL="<{}>".format(comment.get("email")), - AUTHOR_WEBSITE=comment.get("website"), + 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=comment_urls[0], - COMMENT_URL_DELETE=comment_urls[1], - COMMENT_URL_VIEW=comment_urls[2], + 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: dict) -> bool: + def send(self, structured_msg: str) -> bool: """Send the structured message as a notification to the class webhook URL. - :param dict structured_msg: structured message to send + :param str structured_msg: structured message to send :rtype: bool """ - with http.curl("POST", self.wh_url, "/") as resp: - if resp: # may be None if request failed - return resp.status + with Session() as requests_session: + + # send requests + response = requests_session.post( + url=self.wh_url, + data=structured_msg, + headers={ + "Content-Type": "application/json", + "User-Agent": "Isso/{0} (+https://posativ.org/isso)".format( + dist.version + ), + }, + ) # if no error occurred return True From 4d36604fddd1f3d9f00bb247d0f5f43ae27c7616 Mon Sep 17 00:00:00 2001 From: GeoJulien Date: Sat, 1 May 2021 09:13:56 +0200 Subject: [PATCH 10/20] Handle HTTP errors --- isso/ext/notifications.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/isso/ext/notifications.py b/isso/ext/notifications.py index 4f7a9d9e9..797b9eeb6 100644 --- a/isso/ext/notifications.py +++ b/isso/ext/notifications.py @@ -29,7 +29,7 @@ from _thread import start_new_thread -from requests import Session +from requests import HTTPError, Session # Globals logger = logging.getLogger("isso") @@ -389,5 +389,15 @@ def send(self, structured_msg: str) -> bool: }, ) + try: + response.raise_for_status() + logger.info("Web hook sent to %s" % self.wh_url) + except HTTPError as err: + logger.error( + "Something went wrong during POST request to the web hook. Trace: %s" + % err + ) + return False + # if no error occurred return True From 78a0fa41ca908adc5b59107abba26ad61b24b3e4 Mon Sep 17 00:00:00 2001 From: GeoJulien Date: Sat, 1 May 2021 12:25:32 +0200 Subject: [PATCH 11/20] Add webhook template for Slack --- contrib/webhook_template_slack.json | 70 +++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 contrib/webhook_template_slack.json diff --git a/contrib/webhook_template_slack.json b/contrib/webhook_template_slack.json new file mode 100644 index 000000000..57339e7e4 --- /dev/null +++ b/contrib/webhook_template_slack.json @@ -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" + } + ] + } + ] +} From ad64322ee400e7ad39b2aae465b285a16a190d54 Mon Sep 17 00:00:00 2001 From: GeoJulien Date: Sat, 1 May 2021 12:26:00 +0200 Subject: [PATCH 12/20] Align tox dependencies with package --- tox.ini | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tox.ini b/tox.ini index a25e64273..2b9eb3498 100755 --- a/tox.ini +++ b/tox.ini @@ -11,9 +11,11 @@ commands = [testenv:debian] deps= bleach + flask-caching>=1.9,<1.11 html5lib ipaddr==2.1.10 - itsdangerous==0.22 - misaka==1.0.2 + itsdangerous + Jinja2 + misaka>=2.0,<3.0 passlib==1.5.3 - werkzeug==0.8.3 + werkzeug>=1.0 From f122ac67dd3c4952f3121d3b1392a6bf51331ccf Mon Sep 17 00:00:00 2001 From: GeoJulien Date: Sat, 1 May 2021 12:33:59 +0200 Subject: [PATCH 13/20] Add requests as dependency --- setup.py | 12 ++++++++++-- tox.ini | 1 + 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 73038f6fd..e0aebb467 100755 --- a/setup.py +++ b/setup.py @@ -5,8 +5,16 @@ from setuptools import setup, find_packages -requires = ['itsdangerous', 'Jinja2', 'misaka>=2.0,<3.0', 'html5lib', - 'werkzeug>=1.0', 'bleach', 'flask-caching>=1.9'] +requires = [ + "bleach", + "flask-caching>=1.9", + "html5lib", + "itsdangerous", + "Jinja2", + "misaka>=2.0,<3.0", + "requests>=2.25,<2.26", + "werkzeug>=1.0", +] if sys.version_info < (3, ): raise SystemExit("Python 2 is not supported.") diff --git a/tox.ini b/tox.ini index 2b9eb3498..853e35937 100755 --- a/tox.ini +++ b/tox.ini @@ -18,4 +18,5 @@ deps= Jinja2 misaka>=2.0,<3.0 passlib==1.5.3 + requests>=2.25,<2.26 werkzeug>=1.0 From 481819228b566f70b54be8d16897c0bfcba5b582 Mon Sep 17 00:00:00 2001 From: GeoJulien Date: Sat, 1 May 2021 12:34:38 +0200 Subject: [PATCH 14/20] Remove unused var --- isso/ext/notifications.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/isso/ext/notifications.py b/isso/ext/notifications.py index 797b9eeb6..5fd442515 100644 --- a/isso/ext/notifications.py +++ b/isso/ext/notifications.py @@ -349,10 +349,6 @@ def render_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)) - print(type(tpl_str)) - - # build URLs - comment_urls = self.comment_urls(thread, comment) # substitute out_msg = tpl_str.substitute( From 8f07874dccb70e4c1e5a681be1de3e1e868bfa96 Mon Sep 17 00:00:00 2001 From: GeoJulien Date: Sat, 1 May 2021 12:38:56 +0200 Subject: [PATCH 15/20] Complete post data without template --- isso/ext/notifications.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/isso/ext/notifications.py b/isso/ext/notifications.py index 5fd442515..0fc627eba 100644 --- a/isso/ext/notifications.py +++ b/isso/ext/notifications.py @@ -282,13 +282,16 @@ def __iter__(self): yield "comments.new:after-save", self.new_comment - def new_comment(self, thread: dict, comment: dict): + def new_comment(self, thread: dict, comment: dict) -> bool: """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. + :rtype: bool """ try: @@ -299,14 +302,22 @@ def new_comment(self, thread: dict, comment: dict): post_data = self.render_template(thread, comment, moderation_urls) else: post_data = { - "author": comment.get("author", "Anonymous"), + "author_name": comment.get("author", "Anonymous"), "author_email": comment.get("email"), - "text": comment.get("text"), + "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 + + return True def moderation_urls(self, thread: dict, comment: dict) -> tuple: """Helper to build comment related URLs (deletion, activation, etc.). From 386ec00832c80123f844ac8f9531222b64c91b6a Mon Sep 17 00:00:00 2001 From: GeoJulien Date: Sat, 1 May 2021 12:40:59 +0200 Subject: [PATCH 16/20] Enable cache --- .travis.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.travis.yml b/.travis.yml index c43d4c5dc..98cea3599 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,4 +1,6 @@ language: python +cache: pip + matrix: include: - python: 3.5 From 92beeb8d774517db2d18571b445d79e1e9b3622c Mon Sep 17 00:00:00 2001 From: GeoJulien Date: Thu, 17 Jun 2021 07:01:02 +0200 Subject: [PATCH 17/20] Use json loads/dumps to prevent encoding aleas --- isso/ext/notifications.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/isso/ext/notifications.py b/isso/ext/notifications.py index 0fc627eba..d0bf6c32f 100644 --- a/isso/ext/notifications.py +++ b/isso/ext/notifications.py @@ -382,12 +382,15 @@ def send(self, structured_msg: str) -> bool: :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, - data=structured_msg, + json=json.dumps(msg_json), headers={ "Content-Type": "application/json", "User-Agent": "Isso/{0} (+https://posativ.org/isso)".format( From 24f3a2fe3372687637e4089498dbad6ad309e13b Mon Sep 17 00:00:00 2001 From: GeoJulien Date: Fri, 30 Apr 2021 13:35:00 +0200 Subject: [PATCH 18/20] Add myself --- CONTRIBUTORS.txt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CONTRIBUTORS.txt b/CONTRIBUTORS.txt index db1602e55..b195bc816 100644 --- a/CONTRIBUTORS.txt +++ b/CONTRIBUTORS.txt @@ -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] From 4acc0e23d332565a8cce4c85ee0e7a9abb2cc63a Mon Sep 17 00:00:00 2001 From: GeoJulien Date: Fri, 30 Apr 2021 13:15:45 +0200 Subject: [PATCH 19/20] Fix flake8, tox and package config Exclude .venv from flake8 --- isso/dispatch.py | 2 +- isso/run.py | 3 +-- isso/tests/test_guard.py | 2 +- isso/tests/test_vote.py | 41 +++++++++++++++++++++------------------- setup.cfg | 2 +- tox.ini | 8 +++++--- 6 files changed, 31 insertions(+), 27 deletions(-) diff --git a/isso/dispatch.py b/isso/dispatch.py index 6b2867e7d..366eb1d08 100644 --- a/isso/dispatch.py +++ b/isso/dispatch.py @@ -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") diff --git a/isso/run.py b/isso/run.py index 02299c4fb..4f36b9a52 100644 --- a/isso/run.py +++ b/isso/run.py @@ -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( diff --git a/isso/tests/test_guard.py b/isso/tests/test_guard.py index b71c2390d..673219690 100644 --- a/isso/tests/test_guard.py +++ b/isso/tests/test_guard.py @@ -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 diff --git a/isso/tests/test_vote.py b/isso/tests/test_vote.py index f034c48dd..b074cc6c3 100644 --- a/isso/tests/test_vote.py +++ b/isso/tests/test_vote.py @@ -1,7 +1,5 @@ - from __future__ import unicode_literals -import os import json import tempfile import pkg_resources @@ -9,21 +7,21 @@ from werkzeug.wrappers import Response -from isso import Isso, core, config, dist +from isso import Isso, core, config from isso.utils import http from fixtures import curl, loads, FakeIP, JSONClient + http.curl = curl class TestVote(unittest.TestCase): - def setUp(self): self.path = tempfile.NamedTemporaryFile().name def makeClient(self, ip): - conf = config.load(pkg_resources.resource_filename('isso', 'defaults.ini')) + conf = config.load(pkg_resources.resource_filename("isso", "defaults.ini")) conf.set("general", "dbpath", self.path) conf.set("guard", "enabled", "off") conf.set("hash", "algorithm", "none") @@ -39,14 +37,16 @@ class App(Isso, core.Mixin): def testZeroLikes(self): rv = self.makeClient("127.0.0.1").post( - "/new?uri=test", data=json.dumps({"text": "..."})) - self.assertEqual(loads(rv.data)['likes'], 0) - self.assertEqual(loads(rv.data)['dislikes'], 0) + "/new?uri=test", data=json.dumps({"text": "..."}) + ) + self.assertEqual(loads(rv.data)["likes"], 0) + self.assertEqual(loads(rv.data)["dislikes"], 0) def testSingleLike(self): self.makeClient("127.0.0.1").post( - "/new?uri=test", data=json.dumps({"text": "..."})) + "/new?uri=test", data=json.dumps({"text": "..."}) + ) rv = self.makeClient("0.0.0.0").post("/id/1/like") self.assertEqual(rv.status_code, 200) @@ -56,7 +56,7 @@ def testSelfLike(self): bob = self.makeClient("127.0.0.1") bob.post("/new?uri=test", data=json.dumps({"text": "..."})) - rv = bob.post('/id/1/like') + rv = bob.post("/id/1/like") self.assertEqual(rv.status_code, 200) self.assertEqual(loads(rv.data)["likes"], 0) @@ -64,23 +64,25 @@ def testSelfLike(self): def testMultipleLikes(self): self.makeClient("127.0.0.1").post( - "/new?uri=test", data=json.dumps({"text": "..."})) + "/new?uri=test", data=json.dumps({"text": "..."}) + ) for num in range(15): - rv = self.makeClient("1.2.%i.0" % num).post('/id/1/like') + rv = self.makeClient("1.2.%i.0" % num).post("/id/1/like") self.assertEqual(rv.status_code, 200) self.assertEqual(loads(rv.data)["likes"], num + 1) def testVoteOnNonexistentComment(self): - rv = self.makeClient("1.2.3.4").post('/id/1/like') + rv = self.makeClient("1.2.3.4").post("/id/1/like") self.assertEqual(rv.status_code, 200) self.assertEqual(loads(rv.data), None) def testTooManyLikes(self): self.makeClient("127.0.0.1").post( - "/new?uri=test", data=json.dumps({"text": "..."})) + "/new?uri=test", data=json.dumps({"text": "..."}) + ) for num in range(256): - rv = self.makeClient("1.2.%i.0" % num).post('/id/1/like') + rv = self.makeClient("1.2.%i.0" % num).post("/id/1/like") self.assertEqual(rv.status_code, 200) if num >= 142: @@ -90,9 +92,10 @@ def testTooManyLikes(self): def testDislike(self): self.makeClient("127.0.0.1").post( - "/new?uri=test", data=json.dumps({"text": "..."})) - rv = self.makeClient("1.2.3.4").post('/id/1/dislike') + "/new?uri=test", data=json.dumps({"text": "..."}) + ) + rv = self.makeClient("1.2.3.4").post("/id/1/dislike") self.assertEqual(rv.status_code, 200) - self.assertEqual(loads(rv.data)['likes'], 0) - self.assertEqual(loads(rv.data)['dislikes'], 1) + self.assertEqual(loads(rv.data)["likes"], 0) + self.assertEqual(loads(rv.data)["dislikes"], 1) diff --git a/setup.cfg b/setup.cfg index a6e690270..53a901298 100644 --- a/setup.cfg +++ b/setup.cfg @@ -6,4 +6,4 @@ universal=1 [flake8] ignore = E501, E402 -exclude = docs/conf.py,node_modules,.tox,.eggs,.git +exclude = docs/conf.py,node_modules,.tox,.eggs,.git,.venv diff --git a/tox.ini b/tox.ini index a25e64273..2b9eb3498 100755 --- a/tox.ini +++ b/tox.ini @@ -11,9 +11,11 @@ commands = [testenv:debian] deps= bleach + flask-caching>=1.9,<1.11 html5lib ipaddr==2.1.10 - itsdangerous==0.22 - misaka==1.0.2 + itsdangerous + Jinja2 + misaka>=2.0,<3.0 passlib==1.5.3 - werkzeug==0.8.3 + werkzeug>=1.0 From f38c1c0d03d7a0433d04b10e76a384152f4a809d Mon Sep 17 00:00:00 2001 From: GeoJulien Date: Thu, 29 Apr 2021 23:33:11 +0200 Subject: [PATCH 20/20] 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 --- .travis.yml | 2 + contrib/webhook_template_slack.json | 70 ++++++++++ isso/__init__.py | 10 +- isso/ext/notifications.py | 191 +++++++++++++++++++++++++++- setup.py | 12 +- share/isso.conf | 12 ++ tox.ini | 1 + 7 files changed, 290 insertions(+), 8 deletions(-) create mode 100644 contrib/webhook_template_slack.json diff --git a/.travis.yml b/.travis.yml index c43d4c5dc..98cea3599 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,4 +1,6 @@ language: python +cache: pip + matrix: include: - python: 3.5 diff --git a/contrib/webhook_template_slack.json b/contrib/webhook_template_slack.json new file mode 100644 index 000000000..57339e7e4 --- /dev/null +++ b/contrib/webhook_template_slack.json @@ -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" + } + ] + } + ] +} diff --git a/isso/__init__.py b/isso/__init__.py index 4cc3fdd40..9f6dcec01 100644 --- a/isso/__init__.py +++ b/isso/__init__.py @@ -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( @@ -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)) diff --git a/isso/ext/notifications.py b/isso/ext/notifications.py index 4b23525d0..d0bf6c32f 100644 --- a/isso/ext/notifications.py +++ b/isso/ext/notifications.py @@ -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 + +# Globals +logger = logging.getLogger("isso") + class SMTPConnection(object): @@ -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): + """Notification handler for web hook. + + :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. " + "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.") + 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) + ) + 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: + """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. + :rtype: bool + """ + + try: + # get moderation URLs + 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 + + 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 + + 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)) + + # substitute + out_msg = tpl_str.substitute( + 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) + except HTTPError as err: + logger.error( + "Something went wrong during POST request to the web hook. Trace: %s" + % err + ) + return False + + # if no error occurred + return True diff --git a/setup.py b/setup.py index 73038f6fd..e0aebb467 100755 --- a/setup.py +++ b/setup.py @@ -5,8 +5,16 @@ from setuptools import setup, find_packages -requires = ['itsdangerous', 'Jinja2', 'misaka>=2.0,<3.0', 'html5lib', - 'werkzeug>=1.0', 'bleach', 'flask-caching>=1.9'] +requires = [ + "bleach", + "flask-caching>=1.9", + "html5lib", + "itsdangerous", + "Jinja2", + "misaka>=2.0,<3.0", + "requests>=2.25,<2.26", + "werkzeug>=1.0", +] if sys.version_info < (3, ): raise SystemExit("Python 2 is not supported.") diff --git a/share/isso.conf b/share/isso.conf index 1dc8a0323..3607396a2 100644 --- a/share/isso.conf +++ b/share/isso.conf @@ -249,3 +249,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). +# 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. +# An example for Slack with block builder is prodived in the contrib folder. + +# webhook URL +url = + +# path to the JSON template. Optional. +template = diff --git a/tox.ini b/tox.ini index 2b9eb3498..853e35937 100755 --- a/tox.ini +++ b/tox.ini @@ -18,4 +18,5 @@ deps= Jinja2 misaka>=2.0,<3.0 passlib==1.5.3 + requests>=2.25,<2.26 werkzeug>=1.0