Compare commits

...

10 commits
v1.0.1 ... main

Author SHA1 Message Date
cbb311a0a2
fix(nix): Remove unnecessary package group, use python 3.11
All checks were successful
Mirror to Forgejo / mirror-to-codeberg (push) Successful in 2s
2025-01-19 07:45:53 +01:00
251e7a8ef5
fix: Nested f strings don't work on Python < 3.12
All checks were successful
Mirror to Forgejo / mirror-to-codeberg (push) Successful in 2s
2025-01-19 07:45:12 +01:00
5b5351a831
feat: Implement monitoring heartbeat
All checks were successful
Mirror to Forgejo / mirror-to-codeberg (push) Successful in 3s
Closes: #8
2025-01-19 07:16:53 +01:00
daaf79cdbc
fix: Typo in function parameter
All checks were successful
Mirror to Forgejo / mirror-to-codeberg (push) Successful in 3s
2025-01-18 17:27:15 +01:00
f5757736d6
chore: Set up Nix
All checks were successful
Mirror to Forgejo / mirror-to-codeberg (push) Successful in 3s
2025-01-16 15:56:55 +01:00
5a4b30f768
fix(Nextcloud): Catch SSLEOFError when fetching notifications
All checks were successful
Mirror to Forgejo / mirror-to-codeberg (push) Successful in 4s
This happened while the server was down for maintenance. We can most
likely just ignore it.

Fixes: #7
2025-01-16 15:54:33 +01:00
73c47dcfc9
fix: Add authentication and ocs headers to http request action buttons
All checks were successful
Mirror to Forgejo / mirror-to-codeberg (push) Successful in 3s
2025-01-15 20:02:30 +01:00
6eb3ec0cbc
feat(Nextcloud/AppNames): Handle 2FA approval notification
All checks were successful
Mirror to Forgejo / mirror-to-codeberg (push) Successful in 6s
2025-01-15 19:11:50 +01:00
904c712d44
fix: Formatting
Some checks failed
Mirror to Forgejo / mirror-to-codeberg (push) Failing after 3s
2025-01-15 11:55:29 +01:00
c0ccde3b96
fix: Remove extraneous f prefix 2025-01-15 11:45:13 +01:00
5 changed files with 157 additions and 67 deletions

1
.envrc Normal file
View file

@ -0,0 +1 @@
use nix

4
.gitignore vendored
View file

@ -1,3 +1,7 @@
# ---> Nix
.direnv
result/
# ---> Python # ---> Python
# Byte-compiled / optimized / DLL files # Byte-compiled / optimized / DLL files
__pycache__/ __pycache__/

View file

@ -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
View 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
]))
];
}

View file

@ -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", "action": "http",
"label": "Dismiss", "label": "Dismiss",
"url": f"{config["nextcloud_base_url"]}{config["nextcloud_notification_path"]}/{notification["notification_id"]}", "url": f"{config['nextcloud_base_url']}{config['nextcloud_notification_path']}/{notification['notification_id']}",
"method": "DELETE", "method": "DELETE",
"headers": { "headers": {
"Authorization": f"{nextcloud_auth_header}", "Authorization": f"{nextcloud_auth_header}",
"OCS-APIREQUEST": "true" "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)