nextcloud2ntfy/nextcloud2ntfy.py

283 lines
9.3 KiB
Python
Raw Normal View History

2025-01-14 03:21:44 +01:00
import logging as log
import argparse
import requests
import json
import base64
from time import sleep
from datetime import datetime
# Exit codes
# - 1: Response from 'ntfy' was < 400 while pushing notification
# - 2: No ntfy authentication token was provided
# - 3: Provided 'ntfy' authentication token is invalid
2025-01-14 03:21:44 +01:00
log_levels = {
"DEBUG": log.DEBUG,
"INFO": log.INFO,
"WARNING": log.WARNING,
"ERROR": log.ERROR,
2025-01-15 11:55:29 +01:00
"CRITICAL": log.CRITICAL,
2025-01-14 03:21:44 +01:00
}
2025-01-15 11:55:29 +01:00
2025-01-14 03:21:44 +01:00
# Converts Nextcloud's notification buttons to ntfy.sh notification actions
def parse_actions(actions: list, nextcloud_auth_header) -> list:
2025-01-14 03:21:44 +01:00
parsed_actions = []
for action in actions:
action_parsed = {
"action": "http",
"label": f"{action['label']}",
"url": f"{action['link']}",
"method": f"{action['type']}",
2025-01-15 11:55:29 +01:00
"clear": True,
2025-01-14 03:21:44 +01:00
}
# The `action['type']` is documented to be a HTTP request.
# But a value of `WEB` is also used
# which is used for opening links in the browser.
if action_parsed["method"] == "WEB":
del action_parsed["method"]
action_parsed["action"] = "view"
else:
action_parsed["headers"] = {
"Authorization": f"{nextcloud_auth_header}",
}
2025-01-14 03:21:44 +01:00
parsed_actions.append(action_parsed)
return parsed_actions
2025-01-15 11:55:29 +01:00
def push_to_ntfy(
url: str, token: str, topic: str, title: str, click="", message="", actions=[]
) -> requests.Response:
2025-01-14 03:21:44 +01:00
jsonData = {
"topic": f"{topic}",
"title": f"{title}",
"message": f"{message}",
"click": f"{click}",
2025-01-15 11:55:29 +01:00
"actions": actions,
2025-01-14 03:21:44 +01:00
}
if token != "":
2025-01-15 11:55:29 +01:00
response = requests.post(
url, data=json.dumps(jsonData), headers={"Authorization": f"Bearer {token}"}
)
else:
2025-01-15 11:55:29 +01:00
response = requests.post(url, data=json.dumps(jsonData))
2025-01-14 03:21:44 +01:00
return response
2025-01-15 11:55:29 +01:00
2025-01-14 03:21:44 +01:00
# Nextcloud apps have quite often internal names differing from the UI names.
# - Eg. `spreed` is `Talk`
# This is the place to track the differences
2025-01-15 11:55:29 +01:00
def translate_app_name(app=str) -> str:
2025-01-14 03:21:44 +01:00
if app == "spreed":
return "Talk"
elif app == "event_update_notification":
return "Calendar"
elif app == "twofactor_nextcloud_notification":
return "2FA"
2025-01-14 03:21:44 +01:00
else:
return app
2025-01-15 11:55:29 +01:00
2025-01-14 19:59:50 +01:00
def arg_parser() -> argparse.Namespace:
2025-01-15 11:55:29 +01:00
parser = argparse.ArgumentParser(
description="Nextcloud to ntfy.sh notification bridge."
)
2025-01-14 03:21:44 +01:00
parser.add_argument(
2025-01-15 11:55:29 +01:00
"--log_level",
type=str,
default="INFO",
2025-01-14 03:21:44 +01:00
choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
2025-01-15 11:55:29 +01:00
help="Set the logging level (default: INFO)",
2025-01-14 03:21:44 +01:00
)
parser.add_argument(
"-c",
2025-01-15 11:55:29 +01:00
"--config-file",
type=str,
default="./config.json",
2025-01-14 03:21:44 +01:00
required=True,
2025-01-15 11:55:29 +01:00
help="Path to the configuration file",
2025-01-14 03:21:44 +01:00
)
return parser.parse_args()
2025-01-15 11:55:29 +01:00
2025-01-14 03:21:44 +01:00
def load_config(config_file: str) -> dict:
# Default values for the configuration
default_config = {
"ntfy_base_url": "https://ntfy.sh",
2025-01-14 03:21:44 +01:00
"ntfy_topic": "nextcloud",
"ntfy_auth": "false",
"ntfy_token": "authentication_token",
2025-01-14 03:21:44 +01:00
"nextcloud_base_url": "https://nextcloud.example.com",
"nextcloud_notification_path": "/ocs/v2.php/apps/notifications/api/v2/notifications",
2025-01-14 03:21:44 +01:00
"nextcloud_username": "user",
"nextcloud_password": "application_password",
"nextcloud_poll_interval_seconds": 60,
"nextcloud_error_sleep_seconds": 600,
2025-01-14 03:21:44 +01:00
"nextcloud_204_sleep_seconds": 3600,
2025-01-15 11:55:29 +01:00
"rate_limit_sleep_seconds": 600,
2025-01-14 03:21:44 +01:00
}
try:
# Attempt to load the JSON config file
with open(config_file, "r") as file:
config_data = json.load(file)
2025-01-15 11:55:29 +01:00
2025-01-14 03:21:44 +01:00
# Check and fill missing values with defaults
for key, value in default_config.items():
if key not in config_data:
config_data[key] = value
if config_data["ntfy_auth"] == "false":
config_data["ntfy_token"] == ""
elif config_data["ntfy_auth"] == "true" and (
2025-01-15 11:55:29 +01:00
config_data["ntfy_token"] == ""
or config_data["ntfy_token"] == "authentication_token"
):
print(
"Error: Option 'ntfy_auth' is set to 'true' but not 'ntfy_token' was set!"
)
exit(2)
2025-01-15 11:55:29 +01:00
elif config_data["ntfy_auth"] == "true" and not config_data[
"ntfy_token"
].startswith("tk_"):
print("Error: Authentication token set in 'ntfy_token' is invalid!")
exit(3)
2025-01-15 11:55:29 +01:00
2025-01-14 03:21:44 +01:00
return config_data
except FileNotFoundError:
print(f"Configuration file {config_file} not found. Using default values.")
return default_config
except json.JSONDecodeError:
print(f"Error decoding JSON from {config_file}. Using default values.")
return default_config
2025-01-15 11:55:29 +01:00
2025-01-14 03:21:44 +01:00
def main():
args = arg_parser()
config = load_config(args.config_file)
log.basicConfig(
format="{asctime} - {levelname} - {message}",
style="{",
datefmt="%d-%m-%Y %H:%M:%S",
2025-01-15 11:55:29 +01:00
level=log_levels[args.log_level],
2025-01-14 03:21:44 +01:00
)
log.info("Started Nextcloud to ntfy.sh notification bridge.")
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_request_headers = {
"Authorization": f"{nextcloud_auth_header}",
"OCS-APIREQUEST": "true",
2025-01-15 11:55:29 +01:00
"Accept": "application/json",
2025-01-14 03:21:44 +01:00
}
while True:
log.debug("Fetching notifications.")
2025-01-15 11:55:29 +01:00
response = requests.get(
f"{config["nextcloud_base_url"]}{config["nextcloud_notification_path"]}",
headers=nextcloud_request_headers,
)
2025-01-14 03:21:44 +01:00
if not response.ok:
2025-01-15 11:55:29 +01:00
log.error(
f"Error while fetching notifications. Response code: {response.status_code}."
)
log.warning(
f"Sleeping for {config["nextcloud_error_sleep_seconds"]} seconds."
)
2025-01-14 03:21:44 +01:00
sleep(config["nextcloud_error_sleep_seconds"])
continue
elif response.status_code == 204:
2025-01-15 11:55:29 +01:00
log.debug(
f"Got code 204 while fetching notifications. Sleeping for {config["nextcloud_204_sleep_seconds"]/60/60} hour(s)."
)
2025-01-14 03:21:44 +01:00
log.debug(f"Got resonse code: {response.status_code}")
log.debug(f"Received data:\n{response.text}")
try:
data = json.loads(response.text)
except Exception as e:
log.error("Error parsing response from Nextcloud!")
log.error(f"Response code: {response.status_code}")
log.error(f"Response body:\n{response.text}")
2025-01-15 11:45:13 +01:00
log.error("=====================================")
log.error(f"Exception:\n{e}")
2025-01-14 03:21:44 +01:00
for notification in reversed(data["ocs"]["data"]):
if datetime.fromisoformat(notification["datetime"]) <= last_datetime:
log.debug("No new notifications.")
2025-01-14 03:21:44 +01:00
continue
last_datetime = datetime.fromisoformat(notification["datetime"])
log.info("New notifications received.")
title = ""
if notification["app"] == "admin_notifications":
title = f"Nextcloud: {notification["subject"]}"
else:
title = f"Nextcloud - {translate_app_name(notification["app"])}: {notification["subject"]}"
2025-01-14 03:21:44 +01:00
log.debug(f"Notification title: {title}")
message = notification["message"]
log.debug(f"Notification message:\n{message}")
actions = parse_actions(notification["actions"], nextcloud_auth_header)
2025-01-15 11:55:29 +01:00
actions.append(
{
"action": "http",
"label": "Dismiss",
"url": f"{config["nextcloud_base_url"]}{config["nextcloud_notification_path"]}/{notification["notification_id"]}",
"method": "DELETE",
"headers": {
"Authorization": f"{nextcloud_auth_header}",
"OCS-APIREQUEST": "true",
},
"clear": True,
}
)
2025-01-14 03:21:44 +01:00
log.debug(f"Notification actions:\n{actions}")
2025-01-15 11:55:29 +01:00
2025-01-15 11:45:13 +01:00
log.info("Pushing notification to ntfy.")
2025-01-14 03:21:44 +01:00
2025-01-15 11:55:29 +01:00
response = push_to_ntfy(
config["ntfy_base_url"],
config["ntfy_token"],
config["ntfy_topic"],
title,
notification["link"],
message,
actions,
)
2025-01-14 03:21:44 +01:00
if response.status_code == 429:
2025-01-15 11:55:29 +01:00
log.error(
f"Error pushing notification to {config["ntfy_base_url"]}: Too Many Requests."
)
log.warning(
f"Sleeping for {config["rate_limit_sleep_seconds"]} seconds."
)
2025-01-14 03:21:44 +01:00
elif not response.ok:
2025-01-15 11:55:29 +01:00
log.critical(
f"Unknown erroro while pushing notification to {config["ntfy_base_url"]}. Error code: {response.status_code}."
)
2025-01-14 03:21:44 +01:00
log.critical(f"Response: {response.text}")
log.error("Stopping.")
exit(1)
sleep(config["nextcloud_poll_interval_seconds"])
2025-01-15 11:55:29 +01:00
2025-01-14 03:21:44 +01:00
if __name__ == "__main__":
2025-01-15 11:55:29 +01:00
main()