diff --git a/README.md b/README.md
index 1d172abe..03d734df 100644
--- a/README.md
+++ b/README.md
@@ -159,7 +159,7 @@ Just some examples
-Now you can also customise your notification content!
+Now you can also customise your notification content and use Jinja2 templating for their title and body!
## JSON API Monitoring
diff --git a/changedetectionio/forms.py b/changedetectionio/forms.py
index 06aedab2..88c846ae 100644
--- a/changedetectionio/forms.py
+++ b/changedetectionio/forms.py
@@ -193,7 +193,7 @@ class ValidateAppRiseServers(object):
message = field.gettext('\'%s\' is not a valid AppRise URL.' % (server_url))
raise ValidationError(message)
-class ValidateTokensList(object):
+class ValidateJinja2Template(object):
"""
Validates that a {token} is from a valid set
"""
@@ -202,11 +202,24 @@ class ValidateTokensList(object):
def __call__(self, form, field):
from changedetectionio import notification
- regex = re.compile('{.*?}')
- for p in re.findall(regex, field.data):
- if not p.strip('{}') in notification.valid_tokens:
- message = field.gettext('Token \'%s\' is not a valid token.')
- raise ValidationError(message % (p))
+
+ from jinja2 import Environment, BaseLoader, TemplateSyntaxError
+ from jinja2.meta import find_undeclared_variables
+
+
+ try:
+ jinja2_env = Environment(loader=BaseLoader)
+ jinja2_env.globals.update(notification.valid_tokens)
+ rendered = jinja2_env.from_string(field.data).render()
+ except TemplateSyntaxError as e:
+ raise ValidationError(f"This is not a valid Jinja2 template: {e}") from e
+
+ ast = jinja2_env.parse(field.data)
+ undefined = ", ".join(find_undeclared_variables(ast))
+ if undefined:
+ raise ValidationError(
+ f"The following tokens used in the notification are not valid: {undefined}"
+ )
class validateURL(object):
@@ -225,6 +238,7 @@ class validateURL(object):
message = field.gettext('\'%s\' is not a valid URL.' % (field.data.strip()))
raise ValidationError(message)
+
class ValidateListRegex(object):
"""
Validates that anything that looks like a regex passes as a regex
@@ -333,11 +347,11 @@ class quickWatchForm(Form):
# Common to a single watch and the global settings
class commonSettingsForm(Form):
- notification_urls = StringListField('Notification URL list', validators=[validators.Optional(), ValidateAppRiseServers()])
- notification_title = StringField('Notification title', validators=[validators.Optional(), ValidateTokensList()])
- notification_body = TextAreaField('Notification body', validators=[validators.Optional(), ValidateTokensList()])
+ notification_urls = StringListField('Notification URL List', validators=[validators.Optional(), ValidateAppRiseServers()])
+ notification_title = StringField('Notification Title', default='ChangeDetection.io Notification - {{ watch_url }}', validators=[validators.Optional(), ValidateJinja2Template()])
+ notification_body = TextAreaField('Notification Body', default='{{ watch_url }} had a change.', validators=[validators.Optional(), ValidateJinja2Template()])
notification_format = SelectField('Notification format', choices=valid_notification_formats.keys())
- fetch_backend = RadioField(u'Fetch method', choices=content_fetcher.available_fetchers(), validators=[ValidateContentFetcherIsReady()])
+ fetch_backend = RadioField(u'Fetch Method', choices=content_fetcher.available_fetchers(), validators=[ValidateContentFetcherIsReady()])
extract_title_as_title = BooleanField('Extract
from document and use as watch title', default=False)
webdriver_delay = IntegerField('Wait seconds before extracting text', validators=[validators.Optional(), validators.NumberRange(min=1,
message="Should contain one or more seconds")])
diff --git a/changedetectionio/notification.py b/changedetectionio/notification.py
index ec5e9d19..492cdef8 100644
--- a/changedetectionio/notification.py
+++ b/changedetectionio/notification.py
@@ -1,5 +1,7 @@
import apprise
+from jinja2 import Environment, BaseLoader
from apprise import NotifyFormat
+import json
valid_tokens = {
'base_url': '',
@@ -16,8 +18,8 @@ valid_tokens = {
default_notification_format_for_watch = 'System default'
default_notification_format = 'Text'
-default_notification_body = '{watch_url} had a change.\n---\n{diff}\n---\n'
-default_notification_title = 'ChangeDetection.io Notification - {watch_url}'
+default_notification_body = '{{watch_url}} had a change.\n---\n{{diff}}\n---\n'
+default_notification_title = 'ChangeDetection.io Notification - {{watch_url}}'
valid_notification_formats = {
'Text': NotifyFormat.TEXT,
@@ -27,25 +29,67 @@ valid_notification_formats = {
default_notification_format_for_watch: default_notification_format_for_watch
}
-def process_notification(n_object, datastore):
+# include the decorator
+from apprise.decorators import notify
- # Get the notification body from datastore
- n_body = n_object.get('notification_body', default_notification_body)
- n_title = n_object.get('notification_title', default_notification_title)
- n_format = valid_notification_formats.get(
- n_object['notification_format'],
- valid_notification_formats[default_notification_format],
- )
+@notify(on="delete")
+@notify(on="deletes")
+@notify(on="get")
+@notify(on="gets")
+@notify(on="post")
+@notify(on="posts")
+@notify(on="put")
+@notify(on="puts")
+def apprise_custom_api_call_wrapper(body, title, notify_type, *args, **kwargs):
+ import requests
+ url = kwargs['meta'].get('url')
+
+ if url.startswith('post'):
+ r = requests.post
+ elif url.startswith('get'):
+ r = requests.get
+ elif url.startswith('put'):
+ r = requests.put
+ elif url.startswith('delete'):
+ r = requests.delete
+
+ url = url.replace('post://', 'http://')
+ url = url.replace('posts://', 'https://')
+ url = url.replace('put://', 'http://')
+ url = url.replace('puts://', 'https://')
+ url = url.replace('get://', 'http://')
+ url = url.replace('gets://', 'https://')
+ url = url.replace('put://', 'http://')
+ url = url.replace('puts://', 'https://')
+ url = url.replace('delete://', 'http://')
+ url = url.replace('deletes://', 'https://')
+
+ # Try to auto-guess if it's JSON
+ headers = {}
+ try:
+ json.loads(body)
+ headers = {'Content-Type': 'application/json; charset=utf-8'}
+ except ValueError as e:
+ pass
+
+
+ r(url, headers=headers, data=body)
+
+
+def process_notification(n_object, datastore):
# Insert variables into the notification content
notification_parameters = create_notification_parameters(n_object, datastore)
- for n_k in notification_parameters:
- token = '{' + n_k + '}'
- val = notification_parameters[n_k]
- n_title = n_title.replace(token, val)
- n_body = n_body.replace(token, val)
-
+ # Get the notification body from datastore
+ jinja2_env = Environment(loader=BaseLoader)
+ n_body = jinja2_env.from_string(n_object.get('notification_body', default_notification_body)).render(**notification_parameters)
+ n_title = jinja2_env.from_string(n_object.get('notification_title', default_notification_title)).render(**notification_parameters)
+ n_format = valid_notification_formats.get(
+ n_object['notification_format'],
+ valid_notification_formats[default_notification_format],
+ )
+
# https://github.com/caronc/apprise/wiki/Development_LogCapture
# Anything higher than or equal to WARNING (which covers things like Connection errors)
# raise it as an exception
@@ -53,6 +97,7 @@ def process_notification(n_object, datastore):
sent_objs=[]
from .apprise_asset import asset
for url in n_object['notification_urls']:
+ url = jinja2_env.from_string(url).render(**notification_parameters)
apobj = apprise.Apprise(debug=True, asset=asset)
url = url.strip()
if len(url):
@@ -66,7 +111,12 @@ def process_notification(n_object, datastore):
# So if no avatar_url is specified, add one so it can be correctly calculated into the total payload
k = '?' if not '?' in url else '&'
- if not 'avatar_url' in url and not url.startswith('mail'):
+ if not 'avatar_url' in url \
+ and not url.startswith('mail') \
+ and not url.startswith('post') \
+ and not url.startswith('get') \
+ and not url.startswith('delete') \
+ and not url.startswith('put'):
url += k + 'avatar_url=https://raw.githubusercontent.com/dgtlmoon/changedetection.io/master/changedetectionio/static/images/avatar-256x256.png'
if url.startswith('tgram://'):
@@ -144,7 +194,7 @@ def create_notification_parameters(n_object, datastore):
watch_url = n_object['watch_url']
- # Re #148 - Some people have just {base_url} in the body or title, but this may break some notification services
+ # Re #148 - Some people have just {{ base_url }} in the body or title, but this may break some notification services
# like 'Join', so it's always best to atleast set something obvious so that they are not broken.
if base_url == '':
base_url = ""
diff --git a/changedetectionio/static/styles/scss/styles.scss b/changedetectionio/static/styles/scss/styles.scss
index 7e2d3130..3bfa5a12 100644
--- a/changedetectionio/static/styles/scss/styles.scss
+++ b/changedetectionio/static/styles/scss/styles.scss
@@ -877,6 +877,9 @@ body.full-width {
.pure-form-message-inline {
padding-left: 0;
color: var(--color-text-input-description);
+ code {
+ font-size: .875em;
+ }
}
}
diff --git a/changedetectionio/static/styles/styles.css b/changedetectionio/static/styles/styles.css
index d461e82d..c4bfd45e 100644
--- a/changedetectionio/static/styles/styles.css
+++ b/changedetectionio/static/styles/styles.css
@@ -851,6 +851,8 @@ body.full-width .edit-form {
.edit-form .pure-form-message-inline {
padding-left: 0;
color: var(--color-text-input-description); }
+ .edit-form .pure-form-message-inline code {
+ font-size: .875em; }
ul {
padding-left: 1em;
diff --git a/changedetectionio/store.py b/changedetectionio/store.py
index d64f5f4e..1d9bbb15 100644
--- a/changedetectionio/store.py
+++ b/changedetectionio/store.py
@@ -621,4 +621,44 @@ class ChangeDetectionStore:
watch['include_filters'] = [existing_filter]
except:
continue
- return
\ No newline at end of file
+ return
+
+ # Convert old static notification tokens to jinja2 tokens
+ def update_9(self):
+ # Each watch
+ import re
+ # only { } not {{ or }}
+ r = r'(?discord:// only supports a maximum 2,000 characters of notification text, including the title.
tgram:// bots cant send messages to other bots, so you should specify chat ID of non-bot user.
tgram:// only supports very limited HTML and can fail when extra tags are sent, read more here (or use plaintext/markdown format)
+ gets://, posts://, puts://, deletes:// for direct API calls (or omit the "s" for non-SSL ie get://)
@@ -41,8 +42,9 @@
Format for all notifications
-
- These tokens can be used in the notification body and title to customise the notification text.
+
+ You can use Jinja2 templating in the notification title, body and URL.
+
@@ -53,52 +55,49 @@
- {base_url} |
+ {{ '{{ base_url }}' }} |
The URL of the changedetection.io instance you are running. |
- {watch_url} |
+ {{ '{{ watch_url }}' }} |
The URL being watched. |
- {watch_uuid} |
+ {{ '{{ watch_uuid }}' }} |
The UUID of the watch. |
- {watch_title} |
+ {{ '{{ watch_title }}' }} |
The title of the watch. |
- {watch_tag} |
+ {{ '{{ watch_tag }}' }} |
The tag of the watch. |
- {preview_url} |
+ {{ '{{ preview_url }}' }} |
The URL of the preview page generated by changedetection.io. |
- {diff} |
+ {{ '{{ diff_url }}' }} |
The diff output - differences only |
- {diff_full} |
+ {{ '{{ diff_full }}' }} |
The diff output - full difference output |
- {diff_url} |
- The URL of the diff page generated by changedetection.io. |
-
-
- {current_snapshot} |
+ {{ '{{ current_snapshot }}' }} |
The current snapshot value, useful when combined with JSON or CSS filters
|
-
- URLs generated by changedetection.io (such as {diff_url}) require the BASE_URL environment variable set.
- Your BASE_URL var is currently "{{settings_application['current_base_url']}}"
-
+
+
+ URLs generated by changedetection.io (such as {{ '{{ diff_url }}' }}) require the BASE_URL environment variable set.
+ Your BASE_URL var is currently "{{settings_application['current_base_url']}}"
+
{% endmacro %}
diff --git a/changedetectionio/templates/settings.html b/changedetectionio/templates/settings.html
index c912105e..129eb924 100644
--- a/changedetectionio/templates/settings.html
+++ b/changedetectionio/templates/settings.html
@@ -60,7 +60,7 @@
{{ render_field(form.application.form.base_url, placeholder="http://yoursite.com:5000/",
class="m-d") }}
- Base URL used for the {base_url} token in notifications and RSS links.
Default value is the ENV var 'BASE_URL' (Currently "{{settings_application['current_base_url']}}"),
+ Base URL used for the {{ '{{ base_url }}' }} token in notifications and RSS links.
Default value is the ENV var 'BASE_URL' (Currently "{{settings_application['current_base_url']}}"),
read more here.
diff --git a/changedetectionio/tests/test_filter_exist_changes.py b/changedetectionio/tests/test_filter_exist_changes.py
index 10addf0a..27d40345 100644
--- a/changedetectionio/tests/test_filter_exist_changes.py
+++ b/changedetectionio/tests/test_filter_exist_changes.py
@@ -73,17 +73,17 @@ def test_filter_doesnt_exist_then_exists_should_get_notification(client, live_se
# Just a regular notification setting, this will be used by the special 'filter not found' notification
notification_form_data = {"notification_urls": notification_url,
- "notification_title": "New ChangeDetection.io Notification - {watch_url}",
- "notification_body": "BASE URL: {base_url}\n"
- "Watch URL: {watch_url}\n"
- "Watch UUID: {watch_uuid}\n"
- "Watch title: {watch_title}\n"
- "Watch tag: {watch_tag}\n"
- "Preview: {preview_url}\n"
- "Diff URL: {diff_url}\n"
- "Snapshot: {current_snapshot}\n"
- "Diff: {diff}\n"
- "Diff Full: {diff_full}\n"
+ "notification_title": "New ChangeDetection.io Notification - {{watch_url}}",
+ "notification_body": "BASE URL: {{base_url}}\n"
+ "Watch URL: {{watch_url}}\n"
+ "Watch UUID: {{watch_uuid}}\n"
+ "Watch title: {{watch_title}}\n"
+ "Watch tag: {{watch_tag}}\n"
+ "Preview: {{preview_url}}\n"
+ "Diff URL: {{diff_url}}\n"
+ "Snapshot: {{current_snapshot}}\n"
+ "Diff: {{diff}}\n"
+ "Diff Full: {{diff_full}}\n"
":-)",
"notification_format": "Text"}
diff --git a/changedetectionio/tests/test_filter_failure_notification.py b/changedetectionio/tests/test_filter_failure_notification.py
index 7606945f..dd12d79b 100644
--- a/changedetectionio/tests/test_filter_failure_notification.py
+++ b/changedetectionio/tests/test_filter_failure_notification.py
@@ -56,17 +56,17 @@ def run_filter_test(client, content_filter):
# Just a regular notification setting, this will be used by the special 'filter not found' notification
notification_form_data = {"notification_urls": notification_url,
- "notification_title": "New ChangeDetection.io Notification - {watch_url}",
- "notification_body": "BASE URL: {base_url}\n"
- "Watch URL: {watch_url}\n"
- "Watch UUID: {watch_uuid}\n"
- "Watch title: {watch_title}\n"
- "Watch tag: {watch_tag}\n"
- "Preview: {preview_url}\n"
- "Diff URL: {diff_url}\n"
- "Snapshot: {current_snapshot}\n"
- "Diff: {diff}\n"
- "Diff Full: {diff_full}\n"
+ "notification_title": "New ChangeDetection.io Notification - {{watch_url}}",
+ "notification_body": "BASE URL: {{base_url}}\n"
+ "Watch URL: {{watch_url}}\n"
+ "Watch UUID: {{watch_uuid}}\n"
+ "Watch title: {{watch_title}}\n"
+ "Watch tag: {{watch_tag}}\n"
+ "Preview: {{preview_url}}\n"
+ "Diff URL: {{diff_url}}\n"
+ "Snapshot: {{current_snapshot}}\n"
+ "Diff: {{diff}}\n"
+ "Diff Full: {{diff_full}}\n"
":-)",
"notification_format": "Text"}
@@ -84,6 +84,7 @@ def run_filter_test(client, content_filter):
data=notification_form_data,
follow_redirects=True
)
+
assert b"Updated watch." in res.data
time.sleep(3)
diff --git a/changedetectionio/tests/test_notification.py b/changedetectionio/tests/test_notification.py
index e4999962..f1ab4b0d 100644
--- a/changedetectionio/tests/test_notification.py
+++ b/changedetectionio/tests/test_notification.py
@@ -90,17 +90,17 @@ def test_check_notification(client, live_server):
print (">>>> Notification URL: "+notification_url)
notification_form_data = {"notification_urls": notification_url,
- "notification_title": "New ChangeDetection.io Notification - {watch_url}",
- "notification_body": "BASE URL: {base_url}\n"
- "Watch URL: {watch_url}\n"
- "Watch UUID: {watch_uuid}\n"
- "Watch title: {watch_title}\n"
- "Watch tag: {watch_tag}\n"
- "Preview: {preview_url}\n"
- "Diff URL: {diff_url}\n"
- "Snapshot: {current_snapshot}\n"
- "Diff: {diff}\n"
- "Diff Full: {diff_full}\n"
+ "notification_title": "New ChangeDetection.io Notification - {{watch_url}}",
+ "notification_body": "BASE URL: {{base_url}}\n"
+ "Watch URL: {{watch_url}}\n"
+ "Watch UUID: {{watch_uuid}}\n"
+ "Watch title: {{watch_title}}\n"
+ "Watch tag: {{watch_tag}}\n"
+ "Preview: {{preview_url}}\n"
+ "Diff URL: {{diff_url}}\n"
+ "Snapshot: {{current_snapshot}}\n"
+ "Diff: {{diff}}\n"
+ "Diff Full: {{diff_full}}\n"
":-)",
"notification_screenshot": True,
"notification_format": "Text"}
@@ -179,7 +179,6 @@ def test_check_notification(client, live_server):
logging.debug(">>> Skipping BASE_URL check")
-
# This should insert the {current_snapshot}
set_more_modified_response()
client.get(url_for("form_watch_checknow"), follow_redirects=True)
@@ -237,10 +236,9 @@ def test_check_notification(client, live_server):
follow_redirects=True
)
-
def test_notification_validation(client, live_server):
- #live_server_setup(live_server)
- time.sleep(3)
+ time.sleep(1)
+
# re #242 - when you edited an existing new entry, it would not correctly show the notification settings
# Add our URL to the import page
test_url = url_for('test_endpoint', _external=True)
@@ -269,19 +267,33 @@ def test_notification_validation(client, live_server):
# assert b"Notification Body and Title is required when a Notification URL is used" in res.data
# Now adding a wrong token should give us an error
+# Disabled for now
+# res = client.post(
+# url_for("settings_page"),
+# data={"application-notification_title": "New ChangeDetection.io Notification - {{watch_url}}",
+# "application-notification_body": "Rubbish: {{rubbish}}\n",
+# "application-notification_format": "Text",
+# "application-notification_urls": "json://localhost/foobar",
+# "requests-time_between_check-minutes": 180,
+# "fetch_backend": "html_requests"
+# },
+# follow_redirects=True
+# )
+# assert bytes("Token 'rubbish' is not a valid token or is unknown".encode('utf-8')) in res.data
+
+ # And trying to define an invalid Jinja2 template should also throw an error
res = client.post(
url_for("settings_page"),
- data={"application-notification_title": "New ChangeDetection.io Notification - {watch_url}",
- "application-notification_body": "Rubbish: {rubbish}\n",
- "application-notification_format": "Text",
- "application-notification_urls": "json://localhost/foobar",
- "requests-time_between_check-minutes": 180,
- "fetch_backend": "html_requests"
+ data={"application-notification_title": "New ChangeDetection.io Notification - {{ watch_url }}",
+ "application-notification_body": "Rubbish: {{ rubbish }\n",
+ "application-notification_urls": "json://foobar.com",
+ "application-minutes_between_check": 180,
+ "application-fetch_backend": "html_requests"
},
follow_redirects=True
)
+ assert bytes("This is not a valid Jinja2 template".encode('utf-8')) in res.data
- assert bytes("is not a valid token".encode('utf-8')) in res.data
# cleanup for the next
client.get(
@@ -289,4 +301,55 @@ def test_notification_validation(client, live_server):
follow_redirects=True
)
+def test_notification_jinja2(client, live_server):
+ #live_server_setup(live_server)
+ time.sleep(1)
+ # test_endpoint - that sends the contents of a file
+ # test_notification_endpoint - that takes a POST and writes it to file (test-datastore/notification.txt)
+
+ # CUSTOM JSON BODY CHECK for POST://
+ set_original_response()
+ test_notification_url = url_for('test_notification_endpoint', _external=True).replace('http://', 'post://')+"?xxx={{ watch_url }}"
+
+ res = client.post(
+ url_for("settings_page"),
+ data={"application-notification_title": "New ChangeDetection.io Notification - {{ watch_url }}",
+ "application-notification_body": '{ "url" : "{{ watch_url }}", "secret": 444 }',
+ # https://github.com/caronc/apprise/wiki/Notify_Custom_JSON#get-parameter-manipulation
+ "application-notification_urls": test_notification_url,
+ "application-minutes_between_check": 180,
+ "application-fetch_backend": "html_requests"
+ },
+ follow_redirects=True
+ )
+ assert b'Settings updated' in res.data
+
+ # Add a watch and trigger a HTTP POST
+ test_url = url_for('test_endpoint', _external=True)
+ res = client.post(
+ url_for("form_quick_watch_add"),
+ data={"url": test_url, "tag": 'nice one'},
+ follow_redirects=True
+ )
+
+ assert b"Watch added" in res.data
+
+ time.sleep(2)
+ set_modified_response()
+
+ client.get(url_for("form_watch_checknow"), follow_redirects=True)
+ time.sleep(2)
+
+ with open("test-datastore/notification.txt", 'r') as f:
+ x=f.read()
+ j = json.loads(x)
+ assert j['url'].startswith('http://localhost')
+ assert j['secret'] == 444
+
+ # URL check, this will always be converted to lowercase
+ assert os.path.isfile("test-datastore/notification-url.txt")
+ with open("test-datastore/notification-url.txt", 'r') as f:
+ notification_url = f.read()
+ assert 'xxx=http' in notification_url
+ os.unlink("test-datastore/notification-url.txt")
\ No newline at end of file
diff --git a/changedetectionio/tests/util.py b/changedetectionio/tests/util.py
index 39ee4166..fc23c0dd 100644
--- a/changedetectionio/tests/util.py
+++ b/changedetectionio/tests/util.py
@@ -149,6 +149,9 @@ def live_server_setup(live_server):
if data != None:
f.write(data)
+ with open("test-datastore/notification-url.txt", "w") as f:
+ f.write(request.url)
+
print("\n>> Test notification endpoint was hit.\n", data)
return "Text was set"