Compare commits
10 commits
Author | SHA1 | Date | |
---|---|---|---|
cbb311a0a2 | |||
251e7a8ef5 | |||
5b5351a831 | |||
daaf79cdbc | |||
f5757736d6 | |||
5a4b30f768 | |||
73c47dcfc9 | |||
6eb3ec0cbc | |||
904c712d44 | |||
c0ccde3b96 |
5 changed files with 157 additions and 67 deletions
1
.envrc
Normal file
1
.envrc
Normal file
|
@ -0,0 +1 @@
|
||||||
|
use nix
|
4
.gitignore
vendored
4
.gitignore
vendored
|
@ -1,3 +1,7 @@
|
||||||
|
# ---> Nix
|
||||||
|
.direnv
|
||||||
|
result/
|
||||||
|
|
||||||
# ---> Python
|
# ---> Python
|
||||||
# Byte-compiled / optimized / DLL files
|
# Byte-compiled / optimized / DLL files
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"ntfy_base_url": "https://ntfy.sh",
|
"ntfy_base_url": "https://ntfy.sh",
|
||||||
"ntfy_topic": "nextcloud",
|
"ntfy_topic": "nextcloud",
|
||||||
"ntfy_auth": "false",
|
"ntfy_auth": false,
|
||||||
"ntfy_token": "authentication_token",
|
"ntfy_token": "authentication_token",
|
||||||
|
|
||||||
"nextcloud_base_url": "https://nextcloud.example.com",
|
"nextcloud_base_url": "https://nextcloud.example.com",
|
||||||
|
@ -9,6 +9,10 @@
|
||||||
"nextcloud_username": "user",
|
"nextcloud_username": "user",
|
||||||
"nextcloud_password": "application_password",
|
"nextcloud_password": "application_password",
|
||||||
|
|
||||||
|
"heartbeat": false,
|
||||||
|
"heartbeat_url": "url",
|
||||||
|
"heartbeat_interval": 30,
|
||||||
|
|
||||||
"nextcloud_poll_interval_seconds": 60,
|
"nextcloud_poll_interval_seconds": 60,
|
||||||
"nextcloud_error_sleep_seconds": 600,
|
"nextcloud_error_sleep_seconds": 600,
|
||||||
"nextcloud_204_sleep_seconds": 3600,
|
"nextcloud_204_sleep_seconds": 3600,
|
||||||
|
|
17
default.nix
Normal file
17
default.nix
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
# shell.nix
|
||||||
|
let
|
||||||
|
# We pin to a specific nixpkgs commit for reproducibility.
|
||||||
|
# Last updated: 2024-04-29. Check for new commits at https://status.nixos.org.
|
||||||
|
pkgs = import (fetchTarball "https://github.com/NixOS/nixpkgs/archive/cf8cc1201be8bc71b7cbbbdaf349b22f4f99c7ae.tar.gz") {};
|
||||||
|
in pkgs.mkShell {
|
||||||
|
packages = [
|
||||||
|
(pkgs.python311.withPackages (python-pkgs: with python-pkgs; [
|
||||||
|
# select Python packages here
|
||||||
|
python-lsp-server
|
||||||
|
python-lsp-ruff
|
||||||
|
pylsp-mypy
|
||||||
|
pylsp-rope
|
||||||
|
requests
|
||||||
|
]))
|
||||||
|
];
|
||||||
|
}
|
|
@ -1,6 +1,7 @@
|
||||||
import logging as log
|
import logging as log
|
||||||
import argparse
|
import argparse
|
||||||
|
|
||||||
|
import threading
|
||||||
import requests
|
import requests
|
||||||
import json
|
import json
|
||||||
import base64
|
import base64
|
||||||
|
@ -12,17 +13,19 @@ from datetime import datetime
|
||||||
# - 1: Response from 'ntfy' was < 400 while pushing notification
|
# - 1: Response from 'ntfy' was < 400 while pushing notification
|
||||||
# - 2: No ntfy authentication token was provided
|
# - 2: No ntfy authentication token was provided
|
||||||
# - 3: Provided 'ntfy' authentication token is invalid
|
# - 3: Provided 'ntfy' authentication token is invalid
|
||||||
|
# - 4: No heartbeat url was given
|
||||||
|
|
||||||
log_levels = {
|
log_levels = {
|
||||||
"DEBUG": log.DEBUG,
|
"DEBUG": log.DEBUG,
|
||||||
"INFO": log.INFO,
|
"INFO": log.INFO,
|
||||||
"WARNING": log.WARNING,
|
"WARNING": log.WARNING,
|
||||||
"ERROR": log.ERROR,
|
"ERROR": log.ERROR,
|
||||||
"CRITICAL": log.CRITICAL
|
"CRITICAL": log.CRITICAL,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# Converts Nextcloud's notification buttons to ntfy.sh notification actions
|
# Converts Nextcloud's notification buttons to ntfy.sh notification actions
|
||||||
def parse_actions(actions: list) -> list:
|
def parse_actions(actions: list, nextcloud_auth_header) -> list:
|
||||||
parsed_actions = []
|
parsed_actions = []
|
||||||
|
|
||||||
for action in actions:
|
for action in actions:
|
||||||
|
@ -31,7 +34,7 @@ def parse_actions(actions: list) -> list:
|
||||||
"label": f"{action['label']}",
|
"label": f"{action['label']}",
|
||||||
"url": f"{action['link']}",
|
"url": f"{action['link']}",
|
||||||
"method": f"{action['type']}",
|
"method": f"{action['type']}",
|
||||||
"clear": True
|
"clear": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
# The `action['type']` is documented to be a HTTP request.
|
# The `action['type']` is documented to be a HTTP request.
|
||||||
|
@ -40,52 +43,63 @@ def parse_actions(actions: list) -> list:
|
||||||
if action_parsed["method"] == "WEB":
|
if action_parsed["method"] == "WEB":
|
||||||
del action_parsed["method"]
|
del action_parsed["method"]
|
||||||
action_parsed["action"] = "view"
|
action_parsed["action"] = "view"
|
||||||
|
else:
|
||||||
|
action_parsed["headers"] = {
|
||||||
|
"Authorization": f"{nextcloud_auth_header}",
|
||||||
|
"OCS-APIREQUEST": "true",
|
||||||
|
}
|
||||||
|
|
||||||
parsed_actions.append(action_parsed)
|
parsed_actions.append(action_parsed)
|
||||||
|
|
||||||
return parsed_actions
|
return parsed_actions
|
||||||
|
|
||||||
def push_to_ntfy(url: str, token: str, topic: str, title: str, click = "", message = "", actions = []) -> requests.Response:
|
|
||||||
|
def push_to_ntfy(
|
||||||
|
url: str, token: str, topic: str, title: str, click="", message="", actions=[]
|
||||||
|
) -> requests.Response:
|
||||||
jsonData = {
|
jsonData = {
|
||||||
"topic": f"{topic}",
|
"topic": f"{topic}",
|
||||||
"title": f"{title}",
|
"title": f"{title}",
|
||||||
"message": f"{message}",
|
"message": f"{message}",
|
||||||
"click": f"{click}",
|
"click": f"{click}",
|
||||||
"actions": actions
|
"actions": actions,
|
||||||
}
|
}
|
||||||
|
|
||||||
if token != "":
|
if token != "":
|
||||||
response = requests.post(url,
|
response = requests.post(
|
||||||
data=json.dumps(jsonData),
|
url, data=json.dumps(jsonData), headers={"Authorization": f"Bearer {token}"}
|
||||||
headers={
|
)
|
||||||
"Authorization": f"Bearer {token}"
|
|
||||||
})
|
|
||||||
else:
|
else:
|
||||||
response = requests.post(url,
|
response = requests.post(url, data=json.dumps(jsonData))
|
||||||
data=json.dumps(jsonData))
|
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
# Nextcloud apps have quite often internal names differing from the UI names.
|
# Nextcloud apps have quite often internal names differing from the UI names.
|
||||||
# - Eg. `spreed` is `Talk`
|
# - Eg. `spreed` is `Talk`
|
||||||
# This is the place to track the differences
|
# This is the place to track the differences
|
||||||
def translate_app_name(app = str) -> str:
|
def translate_app_name(app: str) -> str:
|
||||||
if app == "spreed":
|
if app == "spreed":
|
||||||
return "Talk"
|
return "Talk"
|
||||||
elif app == "event_update_notification":
|
elif app == "event_update_notification":
|
||||||
return "Calendar"
|
return "Calendar"
|
||||||
|
elif app == "twofactor_nextcloud_notification":
|
||||||
|
return "2FA"
|
||||||
else:
|
else:
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
def arg_parser() -> argparse.Namespace:
|
def arg_parser() -> argparse.Namespace:
|
||||||
parser = argparse.ArgumentParser(description ='Nextcloud to ntfy.sh notification bridge.')
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Nextcloud to ntfy.sh notification bridge."
|
||||||
|
)
|
||||||
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--log_level",
|
"--log_level",
|
||||||
type=str,
|
type=str,
|
||||||
default="INFO",
|
default="INFO",
|
||||||
choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
|
choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
|
||||||
help="Set the logging level (default: INFO)"
|
help="Set the logging level (default: INFO)",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"-c",
|
"-c",
|
||||||
|
@ -93,30 +107,30 @@ def arg_parser() -> argparse.Namespace:
|
||||||
type=str,
|
type=str,
|
||||||
default="./config.json",
|
default="./config.json",
|
||||||
required=True,
|
required=True,
|
||||||
help="Path to the configuration file"
|
help="Path to the configuration file",
|
||||||
)
|
)
|
||||||
|
|
||||||
return parser.parse_args()
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
def load_config(config_file: str) -> dict:
|
def load_config(config_file: str) -> dict:
|
||||||
# Default values for the configuration
|
# Default values for the configuration
|
||||||
default_config = {
|
default_config = {
|
||||||
"ntfy_base_url": "https://ntfy.sh",
|
"ntfy_base_url": "https://ntfy.sh",
|
||||||
"ntfy_topic": "nextcloud",
|
"ntfy_topic": "nextcloud",
|
||||||
"ntfy_auth": "false",
|
"ntfy_auth": False,
|
||||||
"ntfy_token": "authentication_token",
|
"ntfy_token": "authentication_token",
|
||||||
|
|
||||||
"nextcloud_base_url": "https://nextcloud.example.com",
|
"nextcloud_base_url": "https://nextcloud.example.com",
|
||||||
"nextcloud_notification_path": "/ocs/v2.php/apps/notifications/api/v2/notifications",
|
"nextcloud_notification_path": "/ocs/v2.php/apps/notifications/api/v2/notifications",
|
||||||
|
|
||||||
"nextcloud_username": "user",
|
"nextcloud_username": "user",
|
||||||
"nextcloud_password": "application_password",
|
"nextcloud_password": "application_password",
|
||||||
|
"heartbeat": False,
|
||||||
|
"heartbeat_url": "url",
|
||||||
|
"heartbeat_interval": 30,
|
||||||
"nextcloud_poll_interval_seconds": 60,
|
"nextcloud_poll_interval_seconds": 60,
|
||||||
"nextcloud_error_sleep_seconds": 600,
|
"nextcloud_error_sleep_seconds": 600,
|
||||||
"nextcloud_204_sleep_seconds": 3600,
|
"nextcloud_204_sleep_seconds": 3600,
|
||||||
|
"rate_limit_sleep_seconds": 600,
|
||||||
"rate_limit_sleep_seconds": 600
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
@ -129,16 +143,25 @@ def load_config(config_file: str) -> dict:
|
||||||
if key not in config_data:
|
if key not in config_data:
|
||||||
config_data[key] = value
|
config_data[key] = value
|
||||||
|
|
||||||
if config_data["ntfy_auth"] == "false":
|
if config_data["ntfy_auth"] == False:
|
||||||
config_data["ntfy_token"] == ""
|
config_data["ntfy_token"] == ""
|
||||||
elif config_data["ntfy_auth"] == "true" and (
|
elif config_data["ntfy_auth"] == True and (
|
||||||
config_data["ntfy_token"] == "" or config_data["ntfy_token"] == "authentication_token"):
|
config_data["ntfy_token"] == ""
|
||||||
print("Error: Option 'ntfy_auth' is set to 'true' but not 'ntfy_token' was set!")
|
or config_data["ntfy_token"] == "authentication_token"):
|
||||||
|
print(
|
||||||
|
"Error: Option 'ntfy_auth' is set to 'true' but not 'ntfy_token' was set!"
|
||||||
|
)
|
||||||
exit(2)
|
exit(2)
|
||||||
elif config_data["ntfy_auth"] == "true" and not config_data["ntfy_token"].startswith("tk_"):
|
elif config_data["ntfy_auth"] == True and not config_data[
|
||||||
|
"ntfy_token" ].startswith("tk_"):
|
||||||
print("Error: Authentication token set in 'ntfy_token' is invalid!")
|
print("Error: Authentication token set in 'ntfy_token' is invalid!")
|
||||||
exit(3)
|
exit(3)
|
||||||
|
|
||||||
|
if config_data["heartbeat"] == True:
|
||||||
|
if config_data["heartbeat_url"] == "url" or config_data["heartbeat_url"] == "":
|
||||||
|
print("Error: 'heartbeat' is set to 'true' but no url was given.")
|
||||||
|
exit(4)
|
||||||
|
|
||||||
return config_data
|
return config_data
|
||||||
|
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
|
@ -149,6 +172,14 @@ def load_config(config_file: str) -> dict:
|
||||||
print(f"Error decoding JSON from {config_file}. Using default values.")
|
print(f"Error decoding JSON from {config_file}. Using default values.")
|
||||||
return default_config
|
return default_config
|
||||||
|
|
||||||
|
|
||||||
|
def monitoring_heartbeat(url, interval):
|
||||||
|
while True:
|
||||||
|
response = requests.get(url)
|
||||||
|
log.info("Sent heartbeat.")
|
||||||
|
log.debug(f"Response: {response}")
|
||||||
|
sleep(interval)
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
args = arg_parser()
|
args = arg_parser()
|
||||||
config = load_config(args.config_file)
|
config = load_config(args.config_file)
|
||||||
|
@ -157,28 +188,47 @@ def main():
|
||||||
format="{asctime} - {levelname} - {message}",
|
format="{asctime} - {levelname} - {message}",
|
||||||
style="{",
|
style="{",
|
||||||
datefmt="%d-%m-%Y %H:%M:%S",
|
datefmt="%d-%m-%Y %H:%M:%S",
|
||||||
level=log_levels[args.log_level]
|
level=log_levels[args.log_level],
|
||||||
)
|
)
|
||||||
log.info("Started Nextcloud to ntfy.sh notification bridge.")
|
log.info("Started Nextcloud to ntfy.sh notification bridge.")
|
||||||
|
|
||||||
|
if config["heartbeat"] == True:
|
||||||
|
heartbeat = threading.Thread(target=monitoring_heartbeat,
|
||||||
|
daemon=True,
|
||||||
|
args=("https://uptime.stfka.eu/api/push/pRCW5ARYxn?status=up&msg=OK&ping=",
|
||||||
|
config["heartbeat_interval"]))
|
||||||
|
heartbeat.start()
|
||||||
|
|
||||||
last_datetime = datetime.fromisoformat("1970-01-01T00:00:00Z")
|
last_datetime = datetime.fromisoformat("1970-01-01T00:00:00Z")
|
||||||
nextcloud_auth_header = f"Basic {base64.b64encode(f"{config["nextcloud_username"]}:{config["nextcloud_password"]}".encode("utf-8")).decode("utf-8")}"
|
nextcloud_auth_header = f"Basic {base64.b64encode('{}:{}'.format(config['nextcloud_username'], config['nextcloud_password']).encode('utf-8')).decode('utf-8')}"
|
||||||
nextcloud_request_headers = {
|
nextcloud_request_headers = {
|
||||||
"Authorization": f"{nextcloud_auth_header}",
|
"Authorization": f"{nextcloud_auth_header}",
|
||||||
"OCS-APIREQUEST": "true",
|
"OCS-APIREQUEST": "true",
|
||||||
"Accept": "application/json"
|
"Accept": "application/json",
|
||||||
}
|
}
|
||||||
while True:
|
while True:
|
||||||
log.debug("Fetching notifications.")
|
log.debug("Fetching notifications.")
|
||||||
response = requests.get(f"{config["nextcloud_base_url"]}{config["nextcloud_notification_path"]}", headers = nextcloud_request_headers)
|
try:
|
||||||
|
response = requests.get(
|
||||||
|
f"{config['nextcloud_base_url']}{config['nextcloud_notification_path']}",
|
||||||
|
headers=nextcloud_request_headers,
|
||||||
|
)
|
||||||
|
except requests.exceptions.SSLError as e:
|
||||||
|
log.error(f"SSL error fetching notifications. Maybe the server/proxy is down?\n{e}")
|
||||||
if not response.ok:
|
if not response.ok:
|
||||||
log.error(f"Error while fetching notifications. Response code: {response.status_code}.")
|
log.error(
|
||||||
log.warning(f"Sleeping for {config["nextcloud_error_sleep_seconds"]} seconds.")
|
f"Error while fetching notifications. Response code: {response.status_code}."
|
||||||
|
)
|
||||||
|
log.warning(
|
||||||
|
f"Sleeping for {config['nextcloud_error_sleep_seconds']} seconds."
|
||||||
|
)
|
||||||
sleep(config["nextcloud_error_sleep_seconds"])
|
sleep(config["nextcloud_error_sleep_seconds"])
|
||||||
continue
|
continue
|
||||||
|
|
||||||
elif response.status_code == 204:
|
elif response.status_code == 204:
|
||||||
log.debug(f"Got code 204 while fetching notifications. Sleeping for {config["nextcloud_204_sleep_seconds"]/60/60} hour(s).")
|
log.debug(
|
||||||
|
f"Got code 204 while fetching notifications. Sleeping for {config['nextcloud_204_sleep_seconds']/60/60} hour(s)."
|
||||||
|
)
|
||||||
|
|
||||||
log.debug(f"Got resonse code: {response.status_code}")
|
log.debug(f"Got resonse code: {response.status_code}")
|
||||||
|
|
||||||
|
@ -189,12 +239,10 @@ def main():
|
||||||
log.error("Error parsing response from Nextcloud!")
|
log.error("Error parsing response from Nextcloud!")
|
||||||
log.error(f"Response code: {response.status_code}")
|
log.error(f"Response code: {response.status_code}")
|
||||||
log.error(f"Response body:\n{response.text}")
|
log.error(f"Response body:\n{response.text}")
|
||||||
log.error(f"=====================================")
|
log.error("=====================================")
|
||||||
log.error(f"Exception:\n{e}")
|
log.error(f"Exception:\n{e}")
|
||||||
|
|
||||||
|
|
||||||
for notification in reversed(data["ocs"]["data"]):
|
for notification in reversed(data["ocs"]["data"]):
|
||||||
|
|
||||||
if datetime.fromisoformat(notification["datetime"]) <= last_datetime:
|
if datetime.fromisoformat(notification["datetime"]) <= last_datetime:
|
||||||
log.debug("No new notifications.")
|
log.debug("No new notifications.")
|
||||||
continue
|
continue
|
||||||
|
@ -203,36 +251,52 @@ def main():
|
||||||
|
|
||||||
title = ""
|
title = ""
|
||||||
if notification["app"] == "admin_notifications":
|
if notification["app"] == "admin_notifications":
|
||||||
title = f"Nextcloud: {notification["subject"]}"
|
title = f"Nextcloud: {notification['subject']}"
|
||||||
else:
|
else:
|
||||||
title = f"Nextcloud - {translate_app_name(notification["app"])}: {notification["subject"]}"
|
title = f"Nextcloud - {translate_app_name(notification['app'])}: {notification['subject']}"
|
||||||
log.debug(f"Notification title: {title}")
|
log.debug(f"Notification title: {title}")
|
||||||
|
|
||||||
message = notification["message"]
|
message = notification["message"]
|
||||||
log.debug(f"Notification message:\n{message}")
|
log.debug(f"Notification message:\n{message}")
|
||||||
|
|
||||||
actions = parse_actions(notification["actions"])
|
actions = parse_actions(notification["actions"], nextcloud_auth_header)
|
||||||
actions.append({
|
actions.append(
|
||||||
"action": "http",
|
{
|
||||||
"label": "Dismiss",
|
"action": "http",
|
||||||
"url": f"{config["nextcloud_base_url"]}{config["nextcloud_notification_path"]}/{notification["notification_id"]}",
|
"label": "Dismiss",
|
||||||
"method": "DELETE",
|
"url": f"{config['nextcloud_base_url']}{config['nextcloud_notification_path']}/{notification['notification_id']}",
|
||||||
"headers": {
|
"method": "DELETE",
|
||||||
"Authorization": f"{nextcloud_auth_header}",
|
"headers": {
|
||||||
"OCS-APIREQUEST": "true"
|
"Authorization": f"{nextcloud_auth_header}",
|
||||||
},
|
"OCS-APIREQUEST": "true",
|
||||||
"clear": True
|
},
|
||||||
})
|
"clear": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
log.debug(f"Notification actions:\n{actions}")
|
log.debug(f"Notification actions:\n{actions}")
|
||||||
|
|
||||||
log.info(f"Pushing notification to ntfy.")
|
log.info("Pushing notification to ntfy.")
|
||||||
|
|
||||||
response = push_to_ntfy(config["ntfy_base_url"], config["ntfy_token"], config["ntfy_topic"], title, notification["link"], message, actions)
|
response = push_to_ntfy(
|
||||||
|
config["ntfy_base_url"],
|
||||||
|
config["ntfy_token"],
|
||||||
|
config["ntfy_topic"],
|
||||||
|
title,
|
||||||
|
notification["link"],
|
||||||
|
message,
|
||||||
|
actions,
|
||||||
|
)
|
||||||
if response.status_code == 429:
|
if response.status_code == 429:
|
||||||
log.error(f"Error pushing notification to {config["ntfy_base_url"]}: Too Many Requests.")
|
log.error(
|
||||||
log.warning(f"Sleeping for {config["rate_limit_sleep_seconds"]} seconds.")
|
f"Error pushing notification to {config['ntfy_base_url']}: Too Many Requests."
|
||||||
|
)
|
||||||
|
log.warning(
|
||||||
|
f"Sleeping for {config['rate_limit_sleep_seconds']} seconds."
|
||||||
|
)
|
||||||
elif not response.ok:
|
elif not response.ok:
|
||||||
log.critical(f"Unknown erroro while pushing notification to {config["ntfy_base_url"]}. Error code: {response.status_code}.")
|
log.critical(
|
||||||
|
f"Unknown erroro while pushing notification to {config['ntfy_base_url']}. Error code: {response.status_code}."
|
||||||
|
)
|
||||||
log.critical(f"Response: {response.text}")
|
log.critical(f"Response: {response.text}")
|
||||||
log.error("Stopping.")
|
log.error("Stopping.")
|
||||||
exit(1)
|
exit(1)
|
||||||
|
@ -240,4 +304,4 @@ def main():
|
||||||
sleep(config["nextcloud_poll_interval_seconds"])
|
sleep(config["nextcloud_poll_interval_seconds"])
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|
Loading…
Reference in a new issue