From eb7b7b57abca32f5b8f378a455cf1f56767138be Mon Sep 17 00:00:00 2001 From: Jokob-sk Date: Fri, 6 Oct 2023 22:53:15 +1100 Subject: [PATCH 01/13] Notification rework v0.2 --- pialert/__main__.py | 23 ++++- pialert/api.py | 6 +- pialert/const.py | 1 + pialert/helper.py | 11 +-- pialert/notifications.py | 88 +++++++++++++++++ pialert/reporting.py | 209 ++++++++++++++------------------------- 6 files changed, 188 insertions(+), 150 deletions(-) create mode 100644 pialert/notifications.py diff --git a/pialert/__main__.py b/pialert/__main__.py index ae59a7d7..fe7d5221 100755 --- a/pialert/__main__.py +++ b/pialert/__main__.py @@ -24,12 +24,13 @@ import multiprocessing import conf from const import * from logger import mylog -from helper import filePermissions, timeNowTZ, updateState, get_setting_value +from helper import filePermissions, timeNowTZ, updateState, get_setting_value, noti_struc from api import update_api from networkscan import process_scan from initialise import importConfigs from database import DB, get_all_devices -from reporting import send_notifications +from reporting import get_notifications +from notifications import Notifications from plugin import run_plugin_scripts, check_and_run_user_event @@ -146,8 +147,24 @@ def main (): # run all plugins registered to be run when new devices are found pluginsState = run_plugin_scripts(db, 'on_new_device', pluginsState) + # Notification handling + # ---------------------------------------- + # send all configured notifications - send_notifications(db) + notiStructure = get_notifications(db) + + # Write the notifications into the DB + notification = Notifications(db) + + # mylog('debug', f"[MAIN] notiStructure.text: {notiStructure.text} ") + # mylog('debug', f"[MAIN] notiStructure.json: {notiStructure.json} ") + # mylog('debug', f"[MAIN] notiStructure.html: {notiStructure.html} ") + + hasNotifications = notification.create(notiStructure.json, notiStructure.text, notiStructure.html, "") + + if hasNotifications: + pluginsState = run_plugin_scripts(db, 'on_notification', pluginsState) + # Commit SQL db.commitDB() diff --git a/pialert/api.py b/pialert/api.py index 13f21da1..58721fab 100755 --- a/pialert/api.py +++ b/pialert/api.py @@ -3,8 +3,7 @@ import json # pialert modules import conf -from const import (apiPath, sql_devices_all, sql_events_pending_alert, - sql_settings, sql_plugins_events, sql_plugins_history, sql_plugins_objects,sql_language_strings) +from const import (apiPath, sql_devices_all, sql_events_pending_alert, sql_settings, sql_plugins_events, sql_plugins_history, sql_plugins_objects,sql_language_strings, sql_notifications_all) from logger import mylog from helper import write_file @@ -19,8 +18,6 @@ def update_api(db, isNotification = False, updateOnlyDataSources = []): folder = apiPath - # update notifications moved to reporting send_api() - # Save plugins write_file(folder + 'plugins.json' , json.dumps({"data" : conf.plugins})) @@ -33,6 +30,7 @@ def update_api(db, isNotification = False, updateOnlyDataSources = []): ["plugins_history", sql_plugins_history], ["plugins_objects", sql_plugins_objects], ["plugins_language_strings", sql_language_strings], + ["notifications", sql_notifications_all], ["custom_endpoint", conf.API_CUSTOM_SQL], ] diff --git a/pialert/const.py b/pialert/const.py index c5eb0771..cd6daef5 100755 --- a/pialert/const.py +++ b/pialert/const.py @@ -36,6 +36,7 @@ sql_events_pending_alert = "SELECT * FROM Events where eve_PendingAlertEmail is sql_settings = "SELECT * FROM Settings" sql_plugins_objects = "SELECT * FROM Plugins_Objects" sql_language_strings = "SELECT * FROM Plugins_Language_Strings" +sql_notifications_all = "SELECT * FROM Notifications" sql_plugins_events = "SELECT * FROM Plugins_Events" sql_plugins_history = "SELECT * FROM Plugins_History ORDER BY DateTimeChanged DESC" sql_new_devices = """SELECT * FROM ( diff --git a/pialert/helper.py b/pialert/helper.py index 676bc52b..712635b4 100755 --- a/pialert/helper.py +++ b/pialert/helper.py @@ -620,17 +620,8 @@ class json_struc: #------------------------------------------------------------------------------- class noti_struc: - def __init__(self, json, text, html, notificationType): + def __init__(self, json, text, html): self.json = json self.text = text self.html = html - # jsonFile = apiPath + f'/notifications_{notificationType}.json' - - # mylog('debug', [f"[Notifications] Writing {jsonFile}"]) - - # if notificationType != '': - - # # Update .json file - # with open(jsonFile, 'w') as jsonFile: - # json.dump(self, jsonFile, cls=NotiStrucEncoder, indent=4) diff --git a/pialert/notifications.py b/pialert/notifications.py new file mode 100644 index 00000000..875d0591 --- /dev/null +++ b/pialert/notifications.py @@ -0,0 +1,88 @@ +import datetime +import json +import uuid + +# PiAlert modules +import conf +import const +from const import pialertPath, logPath, apiPath +from logger import logResult, mylog, print_log +from helper import timeNowTZ + +#------------------------------------------------------------------------------- +# Notification object handling +#------------------------------------------------------------------------------- +class Notifications: + def __init__(self, db): + self.db = db + + # Create Notifications table if missing + self.db.sql.execute("""CREATE TABLE IF NOT EXISTS "Notifications" ( + "Index" INTEGER, + "GUID" TEXT UNIQUE, + "DateTimeCreated" TEXT, + "DateTimePushed" TEXT, + "Status" TEXT, + "JSON" TEXT, + "Text" TEXT, + "HTML" TEXT, + "PublishedVia" TEXT, + "Extra" TEXT, + PRIMARY KEY("Index" AUTOINCREMENT) + ); + """) + + self.save() + + # Create a new DB entry if new notiifcations available, otherwise skip + def create(self, JSON, Text, HTML, Extra=""): + + # Check if empty JSON + # _json = json.loads(JSON) + # Check if nothing to report + if JSON["internet"] == [] and JSON["new_devices"] == [] and JSON["down_devices"] == [] and JSON["events"] == [] and JSON["plugins"] == []: + self.HasNotifications = False + # end if nothing to report + return self.HasNotifications + + # continue and save into DB if notifications available + self.HasNotifications = True + + self.GUID = str(uuid.uuid4()) + self.DateTimeCreated = timeNowTZ() + self.DateTimePushed = "" + self.Status = "new" + self.JSON = JSON + self.Text = Text + self.HTML = HTML + self.PublishedVia = "" + self.Extra = Extra + + self.upsert() + + return self.HasNotifications + + # Only updates the status + def updateStatus(self, newStatus): + self.Status = newStatus + self.upsert() + + # Updates the Published properties + def updatePublishedVia(self, newPublishedVia): + self.PublishedVia = newPublishedVia + self.DateTimePushed = timeNowTZ() + self.upsert() + + # TODO Index vs hash to minimize SQL calls, finish CRUD operations, expose via API, use API in plugins + + def upsert(self): + self.db.sql.execute(""" + INSERT OR REPLACE INTO Notifications (GUID, DateTimeCreated, DateTimePushed, Status, JSON, Text, HTML, PublishedVia, Extra) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, (self.GUID, self.DateTimeCreated, self.DateTimePushed, self.Status, json.dumps(self.JSON), self.Text, self.HTML, self.PublishedVia, self.Extra)) + + self.save() + + def save(self): + # Commit changes + self.db.commitDB() \ No newline at end of file diff --git a/pialert/reporting.py b/pialert/reporting.py index 0129c425..7157535d 100755 --- a/pialert/reporting.py +++ b/pialert/reporting.py @@ -12,9 +12,7 @@ import datetime import json - import socket - import subprocess import requests from json2table import convert @@ -50,10 +48,10 @@ json_final = [] #------------------------------------------------------------------------------- -def construct_notifications(db, sqlQuery, tableTitle, skipText = False, suppliedJsonStruct = None, notificationType=''): +def construct_notifications(db, sqlQuery, tableTitle, skipText = False, suppliedJsonStruct = None): if suppliedJsonStruct is None and sqlQuery == "": - return noti_struc("", "", "", notificationType) + return noti_struc("", "", "") table_attributes = {"style" : "border-collapse: collapse; font-size: 12px; color:#70707", "width" : "100%", "cellspacing" : 0, "cellpadding" : "3px", "bordercolor" : "#C0C0C0", "border":"1"} headerProps = "width='120px' style='color:white; font-size: 16px;' bgcolor='#64a0d6' " @@ -97,7 +95,7 @@ def construct_notifications(db, sqlQuery, tableTitle, skipText = False, supplied for header in headers: html = format_table(html, header, thProps) - notiStruc = noti_struc(jsn, text, html, notificationType) + notiStruc = noti_struc(jsn, text, html) if not notiStruc.json['data'] and not notiStruc.text and not notiStruc.html: @@ -108,7 +106,7 @@ def construct_notifications(db, sqlQuery, tableTitle, skipText = False, supplied return notiStruc -def send_notifications (db): +def get_notifications (db): sql = db.sql #TO-DO global mail_text, mail_html, json_final, partial_html, partial_txt, partial_json @@ -184,13 +182,12 @@ def send_notifications (db): if 'new_devices' in conf.INCLUDED_SECTIONS : # Compose New Devices Section - sqlQuery = """SELECT eve_MAC as MAC, eve_DateTime as Datetime, dev_LastIP as IP, eve_EventType as "Event Type", dev_Name as "Device name", dev_Comments as Comments, dev_Vendor as "Device Vendor" - FROM Events_Devices + sqlQuery = """SELECT eve_MAC as MAC, eve_DateTime as Datetime, dev_LastIP as IP, eve_EventType as "Event Type", dev_Name as "Device name", dev_Comments as Comments FROM Events_Devices WHERE eve_PendingAlertEmail = 1 AND eve_EventType = 'New Device' ORDER BY eve_DateTime""" - notiStruc = construct_notifications(db, sqlQuery, "New devices", "new_devices") + notiStruc = construct_notifications(db, sqlQuery, "New devices") # collect "new_devices" for the webhook json json_new_devices = notiStruc.json["data"] @@ -201,13 +198,12 @@ def send_notifications (db): if 'down_devices' in conf.INCLUDED_SECTIONS : # Compose Devices Down Section - sqlQuery = """SELECT eve_MAC as MAC, eve_DateTime as Datetime, dev_LastIP as IP, eve_EventType as "Event Type", dev_Name as "Device name", dev_Comments as Comments, dev_Vendor as "Device Vendor" - FROM Events_Devices + sqlQuery = """SELECT eve_MAC as MAC, eve_DateTime as Datetime, dev_LastIP as IP, eve_EventType as "Event Type", dev_Name as "Device name", dev_Comments as Comments FROM Events_Devices WHERE eve_PendingAlertEmail = 1 AND eve_EventType = 'Device Down' ORDER BY eve_DateTime""" - notiStruc = construct_notifications(db, sqlQuery, "Down devices", "down_Devices") + notiStruc = construct_notifications(db, sqlQuery, "Down devices") # collect "down_devices" for the webhook json json_down_devices = notiStruc.json["data"] @@ -218,14 +214,13 @@ def send_notifications (db): if 'events' in conf.INCLUDED_SECTIONS : # Compose Events Section - sqlQuery = """SELECT eve_MAC as MAC, eve_DateTime as Datetime, dev_LastIP as IP, eve_EventType as "Event Type", dev_Name as "Device name", dev_Comments as Comments, dev_Vendor as "Device Vendor" - FROM Events_Devices + sqlQuery = """SELECT eve_MAC as MAC, eve_DateTime as Datetime, dev_LastIP as IP, eve_EventType as "Event Type", dev_Name as "Device name", dev_Comments as Comments FROM Events_Devices WHERE eve_PendingAlertEmail = 1 AND eve_EventType IN ('Connected','Disconnected', 'IP Changed') ORDER BY eve_DateTime""" - notiStruc = construct_notifications(db, sqlQuery, "Events", "events") + notiStruc = construct_notifications(db, sqlQuery, "Events") # collect "events" for the webhook json json_events = notiStruc.json["data"] @@ -250,94 +245,92 @@ def send_notifications (db): plugins_report = len(json_plugins) > 0 mylog('verbose', ['[Notification] Plugins sections done.']) - json_final = { + final_json = { "internet": json_internet, "new_devices": json_new_devices, "down_devices": json_down_devices, - "events": json_events, - "ports": json_ports, + "events": json_events, "plugins": json_plugins, } - mail_text = removeDuplicateNewLines(mail_text) + final_text = removeDuplicateNewLines(mail_text) # Create clickable MAC links - mail_html = generate_mac_links (mail_html, deviceUrl) + final_html = generate_mac_links (mail_html, deviceUrl) # Write output emails for debug - write_file (logPath + '/report_output.json', json.dumps(json_final)) - write_file (logPath + '/report_output.txt', mail_text) - write_file (logPath + '/report_output.html', mail_html) + write_file (logPath + '/report_output.json', json.dumps(final_json)) + write_file (logPath + '/report_output.txt', final_text) + write_file (logPath + '/report_output.html', final_html) - # Write the notifications into the DB - # TODO + return noti_struc(final_json, final_text, final_html) - # Notify is something to report - if json_internet != [] or json_new_devices != [] or json_down_devices != [] or json_events != [] or json_ports != [] or plugins_report: + # # Notify is something to report + # if hasNotifications: - mylog('none', ['[Notification] Changes detected, sending reports']) + # mylog('none', ['[Notification] Changes detected, sending reports']) - msg = noti_struc(json_final, mail_text, mail_html, 'master') + # msg = noti_struc(json_final, mail_text, mail_html) - mylog('minimal', ['[Notification] Udating API files']) - send_api() + # mylog('minimal', ['[Notification] Udating API files']) + # send_api() - if conf.REPORT_MAIL and check_config('email'): - updateState("Send: Email") - mylog('minimal', ['[Notification] Sending report by Email']) - send_email (msg ) - else : - mylog('verbose', ['[Notification] Skip email']) - if conf.REPORT_APPRISE and check_config('apprise'): - updateState("Send: Apprise") - mylog('minimal', ['[Notification] Sending report by Apprise']) - send_apprise (msg) - else : - mylog('verbose', ['[Notification] Skip Apprise']) - if conf.REPORT_WEBHOOK and check_config('webhook'): - updateState("Send: Webhook") - mylog('minimal', ['[Notification] Sending report by Webhook']) - send_webhook (msg) - else : - mylog('verbose', ['[Notification] Skip webhook']) - if conf.REPORT_NTFY and check_config('ntfy'): - updateState("Send: NTFY") - mylog('minimal', ['[Notification] Sending report by NTFY']) - send_ntfy (msg) - else : - mylog('verbose', ['[Notification] Skip NTFY']) - if conf.REPORT_PUSHSAFER and check_config('pushsafer'): - updateState("Send: PUSHSAFER") - mylog('minimal', ['[Notification] Sending report by PUSHSAFER']) - send_pushsafer (msg) - else : - mylog('verbose', ['[Notification] Skip PUSHSAFER']) - # Update MQTT entities - if conf.REPORT_MQTT and check_config('mqtt'): - updateState("Send: MQTT") - mylog('minimal', ['[Notification] Establishing MQTT thread']) - mqtt_start(db) - else : - mylog('verbose', ['[Notification] Skip MQTT']) - else : - mylog('verbose', ['[Notification] No changes to report']) + # if conf.REPORT_MAIL and check_config('email'): + # updateState("Send: Email") + # mylog('minimal', ['[Notification] Sending report by Email']) + # send_email (msg ) + # else : + # mylog('verbose', ['[Notification] Skip email']) + # if conf.REPORT_APPRISE and check_config('apprise'): + # updateState("Send: Apprise") + # mylog('minimal', ['[Notification] Sending report by Apprise']) + # send_apprise (msg) + # else : + # mylog('verbose', ['[Notification] Skip Apprise']) + # if conf.REPORT_WEBHOOK and check_config('webhook'): + # updateState("Send: Webhook") + # mylog('minimal', ['[Notification] Sending report by Webhook']) + # send_webhook (msg) + # else : + # mylog('verbose', ['[Notification] Skip webhook']) + # if conf.REPORT_NTFY and check_config('ntfy'): + # updateState("Send: NTFY") + # mylog('minimal', ['[Notification] Sending report by NTFY']) + # send_ntfy (msg) + # else : + # mylog('verbose', ['[Notification] Skip NTFY']) + # if conf.REPORT_PUSHSAFER and check_config('pushsafer'): + # updateState("Send: PUSHSAFER") + # mylog('minimal', ['[Notification] Sending report by PUSHSAFER']) + # send_pushsafer (msg) + # else : + # mylog('verbose', ['[Notification] Skip PUSHSAFER']) + # # Update MQTT entities + # if conf.REPORT_MQTT and check_config('mqtt'): + # updateState("Send: MQTT") + # mylog('minimal', ['[Notification] Establishing MQTT thread']) + # mqtt_start(db) + # else : + # mylog('verbose', ['[Notification] Skip MQTT']) + # else : + # mylog('verbose', ['[Notification] No changes to report']) - # Clean Pending Alert Events - sql.execute ("""UPDATE Devices SET dev_LastNotification = ? - WHERE dev_MAC IN (SELECT eve_MAC FROM Events - WHERE eve_PendingAlertEmail = 1) - """, (datetime.datetime.now(conf.tz),) ) - sql.execute ("""UPDATE Events SET eve_PendingAlertEmail = 0 - WHERE eve_PendingAlertEmail = 1""") + # # Clean Pending Alert Events + # sql.execute ("""UPDATE Devices SET dev_LastNotification = ? + # WHERE dev_MAC IN (SELECT eve_MAC FROM Events + # WHERE eve_PendingAlertEmail = 1) + # """, (datetime.datetime.now(conf.tz),) ) + # sql.execute ("""UPDATE Events SET eve_PendingAlertEmail = 0 + # WHERE eve_PendingAlertEmail = 1""") - # clear plugin events - sql.execute ("DELETE FROM Plugins_Events") + # # clear plugin events + # sql.execute ("DELETE FROM Plugins_Events") - # DEBUG - print number of rows updated - mylog('minimal', ['[Notification] Notifications changes: ', sql.rowcount]) + # # DEBUG - print number of rows updated + # mylog('minimal', ['[Notification] Notifications changes: ', sql.rowcount]) - # Commit changes - db.commitDB() + # # Commit changes + # db.commitDB() #------------------------------------------------------------------------------- @@ -438,56 +431,6 @@ def skip_repeated_notifications (db): db.commitDB() -#------------------------------------------------------------------------------- -# Notification object handling -#------------------------------------------------------------------------------- -class Notifications: - def __init__(self, db): - - self.db = db - - # Create Notifications table if missing - self.db.sql.execute("""CREATE TABLE IF NOT EXISTS "Notifications" ( - "Index" INTEGER, - "DateTimeCreated" TEXT, - "DateTimePushed" TEXT, - "Status" TEXT, - "JSON" TEXT, - "Text" TEXT, - "HTML" TEXT, - "PublishedVia" TEXT, - "Extra" TEXT, - PRIMARY KEY("Index" AUTOINCREMENT) - ); - """) - - self.save() - - def create(self, JSON, Text, HTML, Extra): - self.JSON = JSON - self.Text = Text - self.HTML = HTML - self.Extra = Extra - self.Status = "new" - - # TODO Init values that can be auto initialized - # TODO Check for nulls - # TODO Index vs hash to minimize SQL calls, finish CRUD operations, expose via API, use API in plugins - - # current_time = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S') - # self.db.sql.execute(""" - # INSERT INTO Notifications (DateTimeCreated, DateTimePushed, Status, JSON, Text, HTML, PublishedVia, Extra) - # VALUES (?, ?, ?, ?, ?, ?, ?, ?) - # """, (current_time, DateTimePushed, Status, JSON, Text, HTML, PublishedVia, Extra)) - - - - def save(self): - - # Commit changes - self.db.commitDB() - - From 695f1593c6d993815ae1b40271cfad47cb6eb29f Mon Sep 17 00:00:00 2001 From: Jokob-sk Date: Sat, 7 Oct 2023 13:00:28 +1100 Subject: [PATCH 02/13] Notification rework v0.3 --- front/php/templates/language/en_us.json | 16 +- front/plugins/_publisher_apprise/apprise.py | 75 ++++-- front/plugins/_publisher_apprise/config.json | 213 +++++++++--------- .../plugins/_publisher_apprise/ignore_plugin | 1 - front/settings.php | 3 +- pialert/__main__.py | 19 +- pialert/appevent.py | 125 ++++++++++ pialert/database.py | 11 +- pialert/helper.py | 4 +- pialert/initialise.py | 10 - pialert/{notifications.py => notification.py} | 33 ++- pialert/plugin.py | 2 +- pialert/publishers/apprise.py | 4 +- pialert/publishers/email.py | 4 +- pialert/publishers/ntfy.py | 4 +- pialert/publishers/pushsafer.py | 4 +- pialert/publishers/webhook.py | 4 +- pialert/reporting.py | 18 +- 18 files changed, 351 insertions(+), 199 deletions(-) delete mode 100755 front/plugins/_publisher_apprise/ignore_plugin create mode 100644 pialert/appevent.py rename pialert/{notifications.py => notification.py} (80%) diff --git a/front/php/templates/language/en_us.json b/front/php/templates/language/en_us.json index 2faee1ae..27620372 100755 --- a/front/php/templates/language/en_us.json +++ b/front/php/templates/language/en_us.json @@ -536,17 +536,7 @@ "WEBHOOK_SIZE_name" : "Max payload size", "WEBHOOK_SIZE_description" : "The maximum size of the webhook payload as number of characters in the passed string. If above limit, it will be truncated and a (text was truncated) message is appended.", "WEBHOOK_SECRET_name": "HMAC Secret", - "WEBHOOK_SECRET_description": "When set, use this secret to generate the SHA256-HMAC hex digest value of the request body, which will be passed as the X-Webhook-Signature header to the request. You can find more informations here.", - "Apprise_display_name" : "Apprise", - "Apprise_icon" : "", - "REPORT_APPRISE_name" : "Enable Apprise", - "REPORT_APPRISE_description" : "Enable sending notifications via Apprise.", - "APPRISE_HOST_name" : "Apprise host URL", - "APPRISE_HOST_description" : "Apprise host URL starting with http:// or https://. (do not forget to include /notify at the end)", - "APPRISE_URL_name" : "Apprise notification URL", - "APPRISE_URL_description" : "Apprise notification target URL. For example for Telegram it would be tgram://{bot_token}/{chat_id}.", - "APPRISE_SIZE_name" : "Max payload size", - "APPRISE_SIZE_description" : "The maximum size of the apprise payload as number of characters in the passed string. If above limit, it will be truncated and a (text was truncated) message is appended.", + "WEBHOOK_SECRET_description": "When set, use this secret to generate the SHA256-HMAC hex digest value of the request body, which will be passed as the X-Webhook-Signature header to the request. You can find more informations here.", "NTFY_display_name" : "NTFY", "NTFY_icon" : "", "REPORT_NTFY_name" : "Enable NTFY", @@ -564,9 +554,7 @@ "REPORT_PUSHSAFER_name" : "Enable Pushsafer", "REPORT_PUSHSAFER_description" : "Enable sending notifications via Pushsafer.", "PUSHSAFER_TOKEN_name" : "Pushsafer token", - "PUSHSAFER_TOKEN_description" : "Your secret Pushsafer API key (token).", - "APPRISE_PAYLOAD_name" : "Payload type", - "APPRISE_PAYLOAD_description" : "Select the payoad type sent to Apprise. For example html works well with emails, text with chat apps, such as Telegram.", + "PUSHSAFER_TOKEN_description" : "Your secret Pushsafer API key (token).", "MQTT_display_name" : "MQTT", "MQTT_icon" : "", "REPORT_TITLE" : "Report", diff --git a/front/plugins/_publisher_apprise/apprise.py b/front/plugins/_publisher_apprise/apprise.py index ac2fc7d0..c3ddb3c8 100755 --- a/front/plugins/_publisher_apprise/apprise.py +++ b/front/plugins/_publisher_apprise/apprise.py @@ -15,7 +15,9 @@ sys.path.extend(["/home/pi/pialert/front/plugins", "/home/pi/pialert/pialert"]) import conf from plugin_helper import Plugin_Objects from logger import mylog, append_line_to_file -from helper import timeNowTZ, noti_struc +from helper import timeNowTZ, noti_obj +from notification import Notification_obj +from database import DB CUR_PATH = str(pathlib.Path(__file__).parent.resolve()) @@ -24,41 +26,60 @@ RESULT_FILE = os.path.join(CUR_PATH, 'last_result.log') def main(): mylog('verbose', ['[APPRISE](publisher) In script']) + + # Check if basic config settings supplied + if check_config() == False: + mylog('none', ['[Check Config] Error: Apprise service not set up correctly. Check your pialert.conf APPRISE_* variables.']) + return - parser = argparse.ArgumentParser(description='APPRISE publisher Plugin') - values = parser.parse_args() + # Create a database connection + db = DB() # instance of class DB + db.open() + # parser = argparse.ArgumentParser(description='APPRISE publisher Plugin') + # values = parser.parse_args() + + # Initialize the Plugin obj output file plugin_objects = Plugin_Objects(RESULT_FILE) - speedtest_result = send() + # Create a Notification_obj instance + Notification_obj(db) - plugin_objects.add_object( - primaryId = 'APPRISE', - secondaryId = timeNowTZ(), - watched1 = speedtest_result['download_speed'], - watched2 = speedtest_result['upload_speed'], - watched3 = 'null', - watched4 = 'null', - extra = 'null', - foreignKey = 'null' - ) + # Retrieve new notifications + new_notifications = notifications.getNew() + + # Process the new notifications + for notification in new_notifications: + + # Send notification + result = send(notification["HTML"], notification["Text"]) + + # Log result + plugin_objects.add_object( + primaryId = 'APPRISE', + secondaryId = timeNowTZ(), + watched1 = notification["GUID"], + watched2 = result, + watched3 = 'null', + watched4 = 'null', + extra = 'null', + foreignKey = 'null' + ) plugin_objects.write_result_file() #------------------------------------------------------------------------------- def check_config(): - if conf.APPRISE_URL == '' or conf.APPRISE_HOST == '': - mylog('none', ['[Check Config] Error: Apprise service not set up correctly. Check your pialert.conf APPRISE_* variables.']) + if conf.APPRISE_URL == '' or conf.APPRISE_HOST == '': return False else: return True #------------------------------------------------------------------------------- -def send(msg: noti_struc): - html = msg.html - text = msg.text +def send(html, text): payloadData = '' + result = '' # limit = 1024 * 1024 # 1MB limit (1024 bytes * 1024 bytes = 1MB) limit = conf.APPRISE_SIZE @@ -66,7 +87,7 @@ def send(msg: noti_struc): # truncate size if conf.APPRISE_PAYLOAD == 'html': if len(msg.html) > limit: - payloadData = msg.html[:limit] + "

(text was truncated)

" + payloadData = msg.html[:limit] + "

(text was truncated)

" else: payloadData = msg.html if conf.APPRISE_PAYLOAD == 'text': @@ -88,14 +109,22 @@ def send(msg: noti_struc): # try runnning a subprocess p = subprocess.Popen(["curl","-i","-X", "POST" ,"-H", "Content-Type:application/json" ,"-d", json.dumps(_json_payload), conf.APPRISE_HOST], stdout=subprocess.PIPE, stderr=subprocess.STDOUT) stdout, stderr = p.communicate() - # write stdout and stderr into .log files for debugging if needed - + # write stdout and stderr into .log files for debugging if needed # Log the stdout and stderr - mylog('debug', [stdout, stderr]) # TO-DO should be changed to mylog + mylog('debug', [stdout, stderr]) + + # log result + result = stdout + except subprocess.CalledProcessError as e: # An error occurred, handle it mylog('none', [e.output]) + # log result + result = e.output + + return result + if __name__ == '__main__': sys.exit(main()) diff --git a/front/plugins/_publisher_apprise/config.json b/front/plugins/_publisher_apprise/config.json index aaa42858..7443919a 100755 --- a/front/plugins/_publisher_apprise/config.json +++ b/front/plugins/_publisher_apprise/config.json @@ -1,22 +1,30 @@ { - "code_name": "internet_speedtest", - "unique_prefix": "INTRSPD", + "code_name": "_publisher_apprise", + "unique_prefix": "APPRISE", "enabled": true, "data_source": "script", "show_ui": true, "localized": ["display_name", "description", "icon"], - "display_name" : [{ + "display_name" : [ + { "language_code": "en_us", - "string" : "Internet speedtest" - }], + "string" : "Apprise publisher" + }, + { + "language_code": "es_es", + "string" : "Habilitar Apprise" + } + ], "icon":[{ "language_code": "en_us", - "string" : "" + "string" : "" }], - "description": [{ + "description": [ + { "language_code": "en_us", - "string" : "A plugin to perform a scheduled internet speedtest." - }], + "string" : "A plugin to publish a notification via the Apprise gateway." + } + ], "params" : [], "database_column_definitions": [ @@ -94,7 +102,7 @@ "localized": ["name"], "name":[{ "language_code": "en_us", - "string" : "Test run on" + "string" : "Sent when" }] }, { @@ -118,60 +126,26 @@ "column": "Watched_Value1", "css_classes": "col-sm-2", "show": true, - "type": "threshold", + "type": "label", "default_value":"", - "options": [ - { - "maximum": 1, - "hexColor": "#D33115" - }, - { - "maximum": 5, - "hexColor": "#792D86" - }, - { - "maximum": 10, - "hexColor": "#7D862D" - }, - { - "maximum": 100, - "hexColor": "#05483C" - } - ], + "options": [], "localized": ["name"], "name":[{ "language_code": "en_us", - "string" : "Download" + "string" : "Notification GUID" }] }, { "column": "Watched_Value2", "css_classes": "col-sm-2", "show": true, - "type": "threshold", + "type": "label", "default_value":"", - "options": [ - { - "maximum": 1, - "hexColor": "#D33115" - }, - { - "maximum": 5, - "hexColor": "#792D86" - }, - { - "maximum": 10, - "hexColor": "#7D862D" - }, - { - "maximum": 100, - "hexColor": "#05483C" - } - ], + "options": [], "localized": ["name"], "name":[{ "language_code": "en_us", - "string" : "Upload" + "string" : "Result" }] }, { @@ -283,7 +257,7 @@ "events": ["run"], "type": "text.select", "default_value":"disabled", - "options": ["disabled", "once", "schedule", "always_after_scan" ], + "options": ["disabled", "on_notification" ], "localized": ["name", "description"], "name" :[{ "language_code": "en_us", @@ -293,15 +267,21 @@ "language_code": "es_es", "string" : "Cuando ejecuta" }], - "description": [{ + "description": [ + { "language_code": "en_us", - "string" : "Enable a regular internet speedtest. If you select schedule the scheduling settings from below are applied. If you select once the scan is run only once on start of the application (container) for the time specified in INTRSPD_RUN_TIMEOUT setting." - }] + "string" : "Enable sending notifications via Apprise." + }, + { + "language_code": "es_es", + "string" : "Habilitar el envío de notificaciones a través de Apprise." + } + ] }, { "function": "CMD", "type": "readonly", - "default_value":"python3 /home/pi/pialert/front/plugins/internet_speedtest/script.py", + "default_value":"python3 /home/pi/pialert/front/plugins/_publisher_apprise/apprise.py", "options": [], "localized": ["name", "description"], "name" : [{ @@ -321,33 +301,10 @@ "string" : "Comando a ejecutar" }] }, - { - "function": "RUN_SCHD", - "type": "text", - "default_value":"*/30 * * * *", - "options": [], - "localized": ["name", "description"], - "name" : [{ - "language_code": "en_us", - "string" : "Schedule" - }, - { - "language_code": "es_es", - "string" : "Schedule" - }], - "description": [{ - "language_code": "en_us", - "string" : "Only enabled if you select schedule in the INTRSPD_RUN setting. Make sure you enter the schedule in the correct cron-like format (e.g. validate at crontab.guru). For example entering 0 4 * * * will run the scan after 4 am in the TIMEZONE you set above. Will be run NEXT time the time passes." - }, - { - "language_code": "es_es", - "string": "Solo habilitado si selecciona schedule en la configuración INTRSPD_RUN. Asegúrese de ingresar el schedule en el formato similar a cron correcto (por ejemplo, valide en crontab.guru). Por ejemplo, ingrese 0 4 * * * ejecutará el escaneo después de las 4 am en el TIMEZONE que configuró arriba . Se ejecutará la PRÓXIMA vez que pase el tiempo." - }] - }, { "function": "RUN_TIMEOUT", "type": "integer", - "default_value":60, + "default_value": 10, "options": [], "localized": ["name", "description"], "name" : [{ @@ -372,46 +329,96 @@ }] }, { - "function": "WATCH", - "type": "text.multiselect", - "default_value":[], - "options": ["Watched_Value1","Watched_Value2","Watched_Value3","Watched_Value4"], + "function": "HOST", + "type": "text", + "default_value": "", + "options": [], "localized": ["name", "description"], - "name" :[{ + "name" : [{ "language_code": "en_us", - "string" : "Watched" + "string" : "Apprise host URL" }, { "language_code": "es_es", - "string" : "Visto" - }], - "description":[{ + "string" : "URL del host de Apprise" + }], + "description": [{ "language_code": "en_us", - "string" : "Send a notification if selected values change. Use CTRL + Click to select/deselect. " - }] + "string" : "Apprise host URL starting with http:// or https://. (do not forget to include /notify at the end)" + }, + { + "language_code": "es_es", + "string" : "URL del host de Apprise que comienza con http:// o https://. (no olvide incluir /notify al final)" + }] }, { - "function": "REPORT_ON", - "type": "text.multiselect", - "default_value":[], - "options": ["new","watched-changed","watched-not-changed", "missing-in-last-scan"], + "function": "URL", + "type": "text", + "default_value": "", + "options": [], "localized": ["name", "description"], - "name" :[{ + "name" : [{ "language_code": "en_us", - "string" : "Report on" + "string" : "Apprise notification URL" }, { "language_code": "es_es", - "string" : "Informar sobre" - }] , - "description":[{ + "string" : "URL de notificación de Apprise" + }], + "description": [{ "language_code": "en_us", - "string" : "Send a notification only on these statuses. new means a new unique (unique combination of PrimaryId and SecondaryId) object was discovered. watched-changed means that selected Watched_ValueN columns changed." + "string" : "Apprise notification target URL. For example for Telegram it would be tgram://{bot_token}/{chat_id}." }, { "language_code": "es_es", - "string" : "Envíe una notificación solo en estos estados. new significa que se descubrió un nuevo objeto único (combinación única de PrimaryId y SecondaryId). watched-changed significa que seleccionó Watched_ValueN Las columnas cambiaron." - }] - } + "string" : "Informar de la URL de destino de la notificación. Por ejemplo, para Telegram sería tgram://{bot_token}/{chat_id}." + }] + }, + { + "function": "PAYLOAD", + "type": "text.select", + "default_value": "html", + "options": ["html", "text"], + "localized": ["name", "description"], + "name" : [{ + "language_code": "en_us", + "string" : "Payload type" + }, + { + "language_code": "es_es", + "string" : "Tipo de carga" + }], + "description": [{ + "language_code": "en_us", + "string" : "Select the payoad type sent to Apprise. For example html works well with emails, text with chat apps, such as Telegram." + }, + { + "language_code": "es_es", + "string" : "Seleccione el tipo de carga útil enviada a Apprise. Por ejemplo, html funciona bien con correos electrónicos, text con aplicaciones de chat, como Telegram." + }] + }, + { + "function": "SIZE", + "type": "integer", + "default_value": 1024, + "options": [], + "localized": ["name", "description"], + "name" : [{ + "language_code": "en_us", + "string" : "Max payload size" + }, + { + "language_code": "es_es", + "string" : "Tamaño máximo de carga útil" + }], + "description": [{ + "language_code": "en_us", + "string" : "The maximum size of the apprise payload as number of characters in the passed string. If above limit, it will be truncated and a (text was truncated) message is appended." + }, + { + "language_code": "es_es", + "string" : "El tamaño máximo de la carga útil de información como número de caracteres en la cadena pasada. Si supera el límite, se truncará y se agregará un mensaje (text was truncated)." + }] + } ] } diff --git a/front/plugins/_publisher_apprise/ignore_plugin b/front/plugins/_publisher_apprise/ignore_plugin deleted file mode 100755 index 77ffa1c1..00000000 --- a/front/plugins/_publisher_apprise/ignore_plugin +++ /dev/null @@ -1 +0,0 @@ -This plugin will not be loaded \ No newline at end of file diff --git a/front/settings.php b/front/settings.php index d18cbcf1..d2d48b85 100755 --- a/front/settings.php +++ b/front/settings.php @@ -749,8 +749,7 @@ while ($row = $result -> fetchArray (SQLITE3_ASSOC)) { // ----------------------------------------------------------------------------- // --------------------------------------------------------- - // Show last time settings have been imported - // getParam("lastImportedTime", "Back_Settings_Imported", skipCache = true); + // Show last time settings have been imported handleLoadingDialog() diff --git a/pialert/__main__.py b/pialert/__main__.py index fe7d5221..fd01c6b6 100755 --- a/pialert/__main__.py +++ b/pialert/__main__.py @@ -24,13 +24,13 @@ import multiprocessing import conf from const import * from logger import mylog -from helper import filePermissions, timeNowTZ, updateState, get_setting_value, noti_struc +from helper import filePermissions, timeNowTZ, updateState, get_setting_value, noti_obj from api import update_api from networkscan import process_scan from initialise import importConfigs -from database import DB, get_all_devices +from database import DB from reporting import get_notifications -from notifications import Notifications +from notification import Notification_obj from plugin import run_plugin_scripts, check_and_run_user_event @@ -154,16 +154,13 @@ def main (): notiStructure = get_notifications(db) # Write the notifications into the DB - notification = Notifications(db) + notification = Notification_obj(db) + hasNotification = notification.create(notiStructure.json, notiStructure.text, notiStructure.html, "") - # mylog('debug', f"[MAIN] notiStructure.text: {notiStructure.text} ") - # mylog('debug', f"[MAIN] notiStructure.json: {notiStructure.json} ") - # mylog('debug', f"[MAIN] notiStructure.html: {notiStructure.html} ") - - hasNotifications = notification.create(notiStructure.json, notiStructure.text, notiStructure.html, "") - - if hasNotifications: + # run all enabled publisher gateways + if hasNotification: pluginsState = run_plugin_scripts(db, 'on_notification', pluginsState) + notification.setAllProcessed() # Commit SQL diff --git a/pialert/appevent.py b/pialert/appevent.py new file mode 100644 index 00000000..acbbe245 --- /dev/null +++ b/pialert/appevent.py @@ -0,0 +1,125 @@ +import datetime +import json +import uuid + +# PiAlert modules +import conf +import const +from const import pialertPath, logPath, apiPath +from logger import logResult, mylog, print_log +from helper import timeNowTZ + +#------------------------------------------------------------------------------- +# Execution object handling +#------------------------------------------------------------------------------- +class AppEvent_obj: + def __init__(self, db): + self.db = db + + # Create AppEvent table if missing + self.db.sql.execute("""CREATE TABLE IF NOT EXISTS "AppEvents" ( + "Index" INTEGER, + "GUID" TEXT UNIQUE, + "DateTimeCreated" TEXT, + "ObjectType" TEXT, -- ObjectType (Plugins, Notifications, Events) + "ObjectGUID" TEXT, + "ObjectPlugin" TEXT, + "ObjectMAC" TEXT, + "ObjectIP" TEXT, + "ObjectPrimaryID" TEXT, + "ObjectSecondaryID" TEXT, + "ObjectForeignKey" TEXT, + "ObjectIndex" TEXT, + "ObjectRowID" TEXT, + "ObjectStatusColumn" TEXT, -- Status (Notifications, Plugins), eve_EventType (Events) + "ObjectStatus" TEXT, -- new_devices, down_devices, events, new, watched-changed, watched-not-changed, missing-in-last-scan, Device down, New Device, IP Changed, Connected, Disconnected, VOIDED - Disconnected, VOIDED - Connected, + "AppEventStatus" TEXT, -- TBD "new", "used", "cleanup-next" + "Extra" TEXT, + PRIMARY KEY("Index" AUTOINCREMENT) + ); + """) + + self.save() + + # Create a new DB entry if new notifications are available, otherwise skip + def create(self, Extra="", **kwargs): + # Check if nothing to report, end + if not any(kwargs.values()): + return False + + # Continue and save into DB if notifications are available + self.GUID = str(uuid.uuid4()) + self.DateTimeCreated = timeNowTZ() + self.ObjectType = "Plugins" # Modify ObjectType as needed + + # Optional parameters + self.ObjectGUID = kwargs.get("ObjectGUID", "") + self.ObjectPlugin = kwargs.get("ObjectPlugin", "") + self.ObjectMAC = kwargs.get("ObjectMAC", "") + self.ObjectIP = kwargs.get("ObjectIP", "") + self.ObjectPrimaryID = kwargs.get("ObjectPrimaryID", "") + self.ObjectSecondaryID = kwargs.get("ObjectSecondaryID", "") + self.ObjectForeignKey = kwargs.get("ObjectForeignKey", "") + self.ObjectIndex = kwargs.get("ObjectIndex", "") + self.ObjectRowID = kwargs.get("ObjectRowID", "") + self.ObjectStatusColumn = kwargs.get("ObjectStatusColumn", "") + self.ObjectStatus = kwargs.get("ObjectStatus", "") + + self.AppEventStatus = "new" # Modify AppEventStatus as needed + self.Extra = Extra + + self.upsert() + + return True + + # Update the status of the entry + def updateStatus(self, newStatus): + self.ObjectStatus = newStatus + self.upsert() + + def upsert(self): + self.db.sql.execute(""" + INSERT OR REPLACE INTO AppEvents ( + "GUID", + "DateTimeCreated", + "ObjectType", + "ObjectGUID", + "ObjectPlugin", + "ObjectMAC", + "ObjectIP", + "ObjectPrimaryID", + "ObjectSecondaryID", + "ObjectForeignKey", + "ObjectIndex", + "ObjectRowID", + "ObjectStatusColumn", + "ObjectStatus", + "AppEventStatus", + "Extra" + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, ( + self.GUID, + self.DateTimeCreated, + self.ObjectType, + self.ObjectGUID, + self.ObjectPlugin, + self.ObjectMAC, + self.ObjectIP, + self.ObjectPrimaryID, + self.ObjectSecondaryID, + self.ObjectForeignKey, + self.ObjectIndex, + self.ObjectRowID, + self.ObjectStatusColumn, + self.ObjectStatus, + self.AppEventStatus, + self.Extra + )) + + self.save() + + def save(self): + # Commit changes + self.db.commitDB() + diff --git a/pialert/database.py b/pialert/database.py index eab5fa05..a9e42e51 100755 --- a/pialert/database.py +++ b/pialert/database.py @@ -6,7 +6,7 @@ import sqlite3 from const import fullDbPath, sql_devices_stats, sql_devices_all from logger import mylog -from helper import json_struc, initOrSetParam, row_to_json, timeNowTZ #, updateState +from helper import json_obj, initOrSetParam, row_to_json, timeNowTZ #, updateState @@ -208,10 +208,7 @@ class DB(): "par_ID" TEXT PRIMARY KEY, "par_Value" TEXT ); - """) - - # Initialize Parameters if unavailable - initOrSetParam(self, 'Back_App_State','Initializing') + """) # ------------------------------------------------------------------------- # Nmap_Scan table setup DEPRECATED after 1/1/2024 @@ -384,7 +381,7 @@ class DB(): rows = self.sql.fetchall() except sqlite3.Error as e: mylog('none',[ '[Database] - SQL ERROR: ', e]) - return None + return json_obj({}, []) # return empty object result = {"data":[]} for row in rows: @@ -392,7 +389,7 @@ class DB(): result["data"].append(tmp) # mylog('debug',[ '[Database] - get_table_as_json - returning ', len(rows), " rows with columns: ", columnNames]) - return json_struc(result, columnNames) + return json_obj(result, columnNames) #------------------------------------------------------------------------------- # referece from here: https://codereview.stackexchange.com/questions/241043/interface-class-for-sqlite-databases diff --git a/pialert/helper.py b/pialert/helper.py index 712635b4..1207ec92 100755 --- a/pialert/helper.py +++ b/pialert/helper.py @@ -613,13 +613,13 @@ def initOrSetParam(db, parID, parValue): db.commitDB() #------------------------------------------------------------------------------- -class json_struc: +class json_obj: def __init__(self, jsn, columnNames): self.json = jsn self.columnNames = columnNames #------------------------------------------------------------------------------- -class noti_struc: +class noti_obj: def __init__(self, json, text, html): self.json = json self.text = text diff --git a/pialert/initialise.py b/pialert/initialise.py index 233bdc0c..68690d26 100755 --- a/pialert/initialise.py +++ b/pialert/initialise.py @@ -134,13 +134,6 @@ def importConfigs (db): conf.WEBHOOK_SIZE = ccd('WEBHOOK_SIZE', 1024 , c_d, 'Payload size', 'integer', '', 'Webhooks') conf.WEBHOOK_SECRET = ccd('WEBHOOK_SECRET', '' , c_d, 'Secret', 'text', '', 'Webhooks') - # Apprise - conf.REPORT_APPRISE = ccd('REPORT_APPRISE', False , c_d, 'Enable Apprise', 'boolean', '', 'Apprise', ['test']) - conf.APPRISE_HOST = ccd('APPRISE_HOST', '' , c_d, 'Apprise host URL', 'text', '', 'Apprise') - conf.APPRISE_URL = ccd('APPRISE_URL', '' , c_d, 'Apprise notification URL', 'text', '', 'Apprise') - conf.APPRISE_PAYLOAD = ccd('APPRISE_PAYLOAD', 'html' , c_d, 'Payload type', 'text.select', "['html', 'text']", 'Apprise') - conf.APPRISE_SIZE = ccd('APPRISE_SIZE', 1024 , c_d, 'Payload size', 'integer', '', 'Apprise') - # NTFY conf.REPORT_NTFY = ccd('REPORT_NTFY', False , c_d, 'Enable NTFY', 'boolean', '', 'NTFY', ['test']) conf.NTFY_HOST = ccd('NTFY_HOST', 'https://ntfy.sh' , c_d, 'NTFY host URL', 'text', '', 'NTFY') @@ -261,9 +254,6 @@ def importConfigs (db): sql.execute ("DELETE FROM Settings") sql.executemany ("""INSERT INTO Settings ("Code_Name", "Display_Name", "Description", "Type", "Options", "RegEx", "Value", "Group", "Events" ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""", conf.mySettingsSQLsafe) - - # Is used to display a message in the UI when old (outdated) settings are loaded - initOrSetParam(db, "Back_Settings_Imported",(round(time.time() * 1000),) ) #commitDB(sql_connection) db.commitDB() diff --git a/pialert/notifications.py b/pialert/notification.py similarity index 80% rename from pialert/notifications.py rename to pialert/notification.py index 875d0591..b9d8a052 100644 --- a/pialert/notifications.py +++ b/pialert/notification.py @@ -12,7 +12,7 @@ from helper import timeNowTZ #------------------------------------------------------------------------------- # Notification object handling #------------------------------------------------------------------------------- -class Notifications: +class Notification_obj: def __init__(self, db): self.db = db @@ -35,11 +35,9 @@ class Notifications: self.save() # Create a new DB entry if new notiifcations available, otherwise skip - def create(self, JSON, Text, HTML, Extra=""): - - # Check if empty JSON - # _json = json.loads(JSON) - # Check if nothing to report + def create(self, JSON, Text, HTML, Extra=""): + + # Check if nothing to report, end if JSON["internet"] == [] and JSON["new_devices"] == [] and JSON["down_devices"] == [] and JSON["events"] == [] and JSON["plugins"] == []: self.HasNotifications = False # end if nothing to report @@ -75,6 +73,7 @@ class Notifications: # TODO Index vs hash to minimize SQL calls, finish CRUD operations, expose via API, use API in plugins + # create or update a notification def upsert(self): self.db.sql.execute(""" INSERT OR REPLACE INTO Notifications (GUID, DateTimeCreated, DateTimePushed, Status, JSON, Text, HTML, PublishedVia, Extra) @@ -83,6 +82,28 @@ class Notifications: self.save() + # Get all with the "new" status + def getNew(self): + self.db.sql.execute(""" + SELECT * FROM Notifications + WHERE Status = "new" + """) + return self.db.sql.fetchall() + + # Set all to "processed" status + def setAllProcessed(self): + + # Execute an SQL query to update the status of all notifications + self.db.sql.execute(""" + UPDATE Notifications + SET Status = "processed" + WHERE Status = "new" + """) + + self.save() + + + def save(self): # Commit changes self.db.commitDB() \ No newline at end of file diff --git a/pialert/plugin.py b/pialert/plugin.py index 6dea2247..70557dcd 100755 --- a/pialert/plugin.py +++ b/pialert/plugin.py @@ -791,7 +791,7 @@ def handle_test(testType): # Open json sample and get only the payload part sample_json_payload = json.loads(get_file_content(pialertPath + '/back/webhook_json_sample.json'))[0]["body"]["attachments"][0]["text"] - sample_msg = noti_struc(sample_json_payload, sample_txt, sample_html, "test_sample") + sample_msg = noti_obj(sample_json_payload, sample_txt, sample_html, "test_sample") if testType == 'Email': diff --git a/pialert/publishers/apprise.py b/pialert/publishers/apprise.py index 0f2ed35c..6be9f0e5 100755 --- a/pialert/publishers/apprise.py +++ b/pialert/publishers/apprise.py @@ -2,7 +2,7 @@ import json import subprocess import conf -from helper import noti_struc +from helper import noti_obj from logger import logResult, mylog #------------------------------------------------------------------------------- @@ -14,7 +14,7 @@ def check_config(): return True #------------------------------------------------------------------------------- -def send(msg: noti_struc): +def send(msg: noti_obj): html = msg.html text = msg.text diff --git a/pialert/publishers/email.py b/pialert/publishers/email.py index c957d006..acf37834 100755 --- a/pialert/publishers/email.py +++ b/pialert/publishers/email.py @@ -7,7 +7,7 @@ import smtplib import conf import socket -from helper import hide_email, noti_struc +from helper import hide_email, noti_obj from logger import mylog, print_log #------------------------------------------------------------------------------- @@ -19,7 +19,7 @@ def check_config (): return True #------------------------------------------------------------------------------- -def send (msg: noti_struc): +def send (msg: noti_obj): pText = msg.text pHTML = msg.html diff --git a/pialert/publishers/ntfy.py b/pialert/publishers/ntfy.py index 957657cf..fd613f78 100755 --- a/pialert/publishers/ntfy.py +++ b/pialert/publishers/ntfy.py @@ -4,7 +4,7 @@ import requests from base64 import b64encode from logger import mylog -from helper import noti_struc +from helper import noti_obj #------------------------------------------------------------------------------- def check_config(): @@ -15,7 +15,7 @@ def check_config(): return True #------------------------------------------------------------------------------- -def send (msg: noti_struc): +def send (msg: noti_obj): headers = { "Title": "Pi.Alert Notification", diff --git a/pialert/publishers/pushsafer.py b/pialert/publishers/pushsafer.py index b8252209..eaf97c57 100755 --- a/pialert/publishers/pushsafer.py +++ b/pialert/publishers/pushsafer.py @@ -3,7 +3,7 @@ import requests import conf -from helper import noti_struc +from helper import noti_obj from logger import mylog #------------------------------------------------------------------------------- @@ -15,7 +15,7 @@ def check_config(): return True #------------------------------------------------------------------------------- -def send ( msg:noti_struc ): +def send ( msg:noti_obj ): _Text = msg.text url = 'https://www.pushsafer.com/api' post_fields = { diff --git a/pialert/publishers/webhook.py b/pialert/publishers/webhook.py index d530ac7a..7397e714 100755 --- a/pialert/publishers/webhook.py +++ b/pialert/publishers/webhook.py @@ -5,7 +5,7 @@ import hmac import conf from const import logPath -from helper import noti_struc, write_file +from helper import noti_obj, write_file from logger import logResult, mylog #------------------------------------------------------------------------------- @@ -18,7 +18,7 @@ def check_config(): #------------------------------------------------------------------------------- -def send (msg: noti_struc): +def send (msg: noti_obj): # limit = 1024 * 1024 # 1MB limit (1024 bytes * 1024 bytes = 1MB) limit = conf.WEBHOOK_SIZE diff --git a/pialert/reporting.py b/pialert/reporting.py index 7157535d..7cf512a4 100755 --- a/pialert/reporting.py +++ b/pialert/reporting.py @@ -21,7 +21,7 @@ from json2table import convert import conf import const from const import pialertPath, logPath, apiPath -from helper import noti_struc, generate_mac_links, removeDuplicateNewLines, timeNowTZ, hide_email, updateState, get_file_content, write_file +from helper import noti_obj, generate_mac_links, removeDuplicateNewLines, timeNowTZ, hide_email, updateState, get_file_content, write_file from logger import logResult, mylog, print_log from publishers.email import (check_config as email_check_config, @@ -51,7 +51,7 @@ json_final = [] def construct_notifications(db, sqlQuery, tableTitle, skipText = False, suppliedJsonStruct = None): if suppliedJsonStruct is None and sqlQuery == "": - return noti_struc("", "", "") + return noti_obj("", "", "") table_attributes = {"style" : "border-collapse: collapse; font-size: 12px; color:#70707", "width" : "100%", "cellspacing" : 0, "cellpadding" : "3px", "bordercolor" : "#C0C0C0", "border":"1"} headerProps = "width='120px' style='color:white; font-size: 16px;' bgcolor='#64a0d6' " @@ -61,11 +61,11 @@ def construct_notifications(db, sqlQuery, tableTitle, skipText = False, supplied text_line = '{}\t{}\n' if suppliedJsonStruct is None: - json_struc = db.get_table_as_json(sqlQuery) + json_obj = db.get_table_as_json(sqlQuery) else: - json_struc = suppliedJsonStruct + json_obj = suppliedJsonStruct - jsn = json_struc.json + jsn = json_obj.json html = "" text = "" @@ -78,7 +78,7 @@ def construct_notifications(db, sqlQuery, tableTitle, skipText = False, supplied # Cleanup the generated HTML table notification html = format_table(html, "data", headerProps, tableTitle).replace('
    ','
      ').replace("null", "") - headers = json_struc.columnNames + headers = json_obj.columnNames # prepare text-only message if skipText == False: @@ -95,7 +95,7 @@ def construct_notifications(db, sqlQuery, tableTitle, skipText = False, supplied for header in headers: html = format_table(html, header, thProps) - notiStruc = noti_struc(jsn, text, html) + notiStruc = noti_obj(jsn, text, html) if not notiStruc.json['data'] and not notiStruc.text and not notiStruc.html: @@ -263,14 +263,14 @@ def get_notifications (db): write_file (logPath + '/report_output.txt', final_text) write_file (logPath + '/report_output.html', final_html) - return noti_struc(final_json, final_text, final_html) + return noti_obj(final_json, final_text, final_html) # # Notify is something to report # if hasNotifications: # mylog('none', ['[Notification] Changes detected, sending reports']) - # msg = noti_struc(json_final, mail_text, mail_html) + # msg = noti_obj(json_final, mail_text, mail_html) # mylog('minimal', ['[Notification] Udating API files']) # send_api() From 93c45d7157278098ad9e0d3dfb79dc4f58c00561 Mon Sep 17 00:00:00 2001 From: Scott Roach Date: Fri, 6 Oct 2023 23:18:45 -0700 Subject: [PATCH 03/13] Show current device icon as it changes --- front/deviceDetails.php | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/front/deviceDetails.php b/front/deviceDetails.php index d32a1f5c..fabccd03 100755 --- a/front/deviceDetails.php +++ b/front/deviceDetails.php @@ -146,10 +146,7 @@
      -
      - - -
      +
      @@ -159,7 +156,6 @@
      -
      @@ -176,7 +172,6 @@
      -
      @@ -196,9 +191,9 @@
      - + + '> -
      @@ -749,6 +742,11 @@ function main () { } }); + // Show device icon as it changes + $('#txtIcon').on('change input', function() { + $('#txtIconFA').removeClass().addClass(`fa fa-${$(this).val()} pointer`) + }); + }); }); @@ -1285,7 +1283,8 @@ function getDeviceData (readAllData=false) { $('#txtOwner').val (deviceData['dev_Owner']); $('#txtDeviceType').val (deviceData['dev_DeviceType']); $('#txtVendor').val (deviceData['dev_Vendor']); - $('#txtIcon').val (initDefault(deviceData['dev_Icon'], 'laptop')); + $('#txtIcon').val (initDefault(deviceData['dev_Icon'], 'laptop')); + $('#txtIcon').trigger('change') if (deviceData['dev_Favorite'] == 1) {$('#chkFavorite').iCheck('check');} else {$('#chkFavorite').iCheck('uncheck');} $('#txtGroup').val (deviceData['dev_Group']); @@ -1700,6 +1699,7 @@ function setTextValue (textElement, textValue) { $('#'+textElement).attr ('data-myvalue', textValue); $('#'+textElement).val (textValue); } + $('#'+textElement).trigger('change') } // ----------------------------------------------------------------------------- @@ -1818,3 +1818,9 @@ function toggleNetworkConfiguration(disable) } + + \ No newline at end of file From 4aad8c12f8f4022b7c1300ad0fbe79fb1a43fb03 Mon Sep 17 00:00:00 2001 From: Scott Roach Date: Fri, 6 Oct 2023 23:20:22 -0700 Subject: [PATCH 04/13] Space out network icons, fix invalid markup, and overall slight cleanup --- front/network.php | 64 ++++++++++++++++++++++------------------------- 1 file changed, 30 insertions(+), 34 deletions(-) diff --git a/front/network.php b/front/network.php index a8c6ddcc..62080327 100755 --- a/front/network.php +++ b/front/network.php @@ -617,23 +617,20 @@ var sizeCoefficient = 1 function initTree(myHierarchy) - { + { // calculate the font size of the leaf nodes to fit everything into the tree area leafNodesCount == 0 ? 1 : leafNodesCount; - emSize = ((treeAreaHeight/(25*leafNodesCount)).toFixed(2)); - emSize = emSize > 1 ? 1 : emSize; - + emSize = ((treeAreaHeight/(25*leafNodesCount)).toFixed(2)); + emSize = emSize > 1 ? 1 : emSize; + // nodeHeight = ((emSize*100*0.30).toFixed(0)) nodeHeight = ((emSize*100*0.30).toFixed(0)) $("#networkTree").attr('style', `height:${treeAreaHeight}px; width:${$('.content-header').width()}px`) - console.log('here') - myTree = Treeviz.create({ htmlId: "networkTree", - - renderNode: nodeData => { + renderNode: nodeData => { var fontSize = "font-size:"+emSize+"em;"; (!emptyArr.includes(nodeData.data.port )) ? port = nodeData.data.port : port = ""; @@ -641,52 +638,51 @@ (port == "" || port == 0 ) ? portBckgIcon = `` : portBckgIcon = ``; // Build HTML for individual nodes in the network diagram - deviceIcon = (!emptyArr.includes(nodeData.data.icon )) ? "
      " : ""; - devicePort = `
      ${port}
      ${portBckgIcon}
      `; - collapseExpandIcon = nodeData.data.hiddenChildren ? "square-plus" :"square-minus"; - collapseExpandHtml = (nodeData.data.hasChildren) ? "
      " : ""; - statusCss = " netStatus-" + nodeData.data.status; + deviceIcon = (!emptyArr.includes(nodeData.data.icon )) ? `
      ` : ""; + devicePort = `
      ${port}
      ${portBckgIcon}
      `; + collapseExpandIcon = nodeData.data.hiddenChildren ? "square-plus" : "square-minus"; + collapseExpandHtml = nodeData.data.hasChildren ? `
      ` : ""; + statusCss = ` netStatus-${nodeData.data.status}`; selectedNodeMac = $(".nav-tabs-custom .active a").attr('data-mytabmac') - highlightedCss = nodeData.data.mac == selectedNodeMac ? " highlightedNode" : ""; + highlightedCss = nodeData.data.mac == selectedNodeMac ? " highlightedNode" : ""; - return result = `
      \ - ${devicePort} ${deviceIcon} - ${nodeData.data.name}\ - - ${collapseExpandHtml} -
      `; - }, +
      + ${devicePort} ${deviceIcon} + ${nodeData.data.name} + + ${collapseExpandHtml} +
      +
      `; + }, - onNodeClick: nodeData => { - console.log(this) - }, + onNodeClick: nodeData => { + console.log(this) + }, mainAxisNodeSpacing: 'auto', // mainAxisNodeSpacing: 3, secondaryAxisNodeSpacing: 0.3, - nodeHeight: nodeHeight.toString(), + nodeHeight: nodeHeight.toString(), marginTop: '5', hasZoom: false, hasPan: false, // marginLeft: '15', idKey: "id", - hasFlatData: false, + hasFlatData: false, linkWidth: (nodeData) => 3, linkColor: (nodeData) => "#ffcc80", onNodeClick: (nodeData) => handleNodeClick(nodeData), - relationnalField: "children", + relationnalField: "children", }); - console.log(myHierarchy) - - - myTree.refresh(myHierarchy); + + myTree.refresh(myHierarchy); } // --------------------------------------------------------------------------- From e018fe299550175302467fe9ea459b2bfc8dc491 Mon Sep 17 00:00:00 2001 From: Scott Roach Date: Fri, 6 Oct 2023 23:21:34 -0700 Subject: [PATCH 05/13] Related CSS for network icon/text alignment --- front/css/pialert.css | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/front/css/pialert.css b/front/css/pialert.css index a9d952f1..9056cb17 100755 --- a/front/css/pialert.css +++ b/front/css/pialert.css @@ -795,7 +795,8 @@ input[readonly] { #networkTree .netPort { float:left; - display:inline; + display:inline; + text-align: center; } #networkTree .portBckgIcon @@ -816,7 +817,8 @@ input[readonly] { { width: 25px;; float:left; - display:inline; + display:inline; + text-align: center; } #networkTree .netCollapse { From d4b590a9fc119e5b4862d473569734d35c29d230 Mon Sep 17 00:00:00 2001 From: Jokob-sk Date: Sat, 7 Oct 2023 18:04:33 +1100 Subject: [PATCH 06/13] Notification rework v0.4 --- front/plugins/_publisher_apprise/apprise.py | 16 ++++++++-------- pialert/__main__.py | 17 ++++++++++++++++- pialert/reporting.py | 16 ---------------- 3 files changed, 24 insertions(+), 25 deletions(-) diff --git a/front/plugins/_publisher_apprise/apprise.py b/front/plugins/_publisher_apprise/apprise.py index c3ddb3c8..85a61f38 100755 --- a/front/plugins/_publisher_apprise/apprise.py +++ b/front/plugins/_publisher_apprise/apprise.py @@ -15,7 +15,7 @@ sys.path.extend(["/home/pi/pialert/front/plugins", "/home/pi/pialert/pialert"]) import conf from plugin_helper import Plugin_Objects from logger import mylog, append_line_to_file -from helper import timeNowTZ, noti_obj +from helper import timeNowTZ, noti_obj, get_setting_value from notification import Notification_obj from database import DB @@ -70,7 +70,7 @@ def main(): #------------------------------------------------------------------------------- def check_config(): - if conf.APPRISE_URL == '' or conf.APPRISE_HOST == '': + if get_setting_value('APPRISE_URL') == '' or get_setting_value('APPRISE_HOST') == '': return False else: return True @@ -82,15 +82,15 @@ def send(html, text): result = '' # limit = 1024 * 1024 # 1MB limit (1024 bytes * 1024 bytes = 1MB) - limit = conf.APPRISE_SIZE + limit = get_setting_value('APPRISE_SIZE') # truncate size - if conf.APPRISE_PAYLOAD == 'html': + if get_setting_value('APPRISE_PAYLOAD') == 'html': if len(msg.html) > limit: payloadData = msg.html[:limit] + "

      (text was truncated)

      " else: payloadData = msg.html - if conf.APPRISE_PAYLOAD == 'text': + if get_setting_value('APPRISE_PAYLOAD') == 'text': if len(msg.text) > limit: payloadData = msg.text[:limit] + " (text was truncated)" else: @@ -99,15 +99,15 @@ def send(html, text): # Define Apprise compatible payload (https://github.com/caronc/apprise-api#stateless-solution) _json_payload = { - "urls": conf.APPRISE_URL, + "urls": get_setting_value('APPRISE_URL'), "title": "Pi.Alert Notifications", - "format": conf.APPRISE_PAYLOAD, + "format": get_setting_value('APPRISE_PAYLOAD'), "body": payloadData } try: # try runnning a subprocess - p = subprocess.Popen(["curl","-i","-X", "POST" ,"-H", "Content-Type:application/json" ,"-d", json.dumps(_json_payload), conf.APPRISE_HOST], stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + p = subprocess.Popen(["curl","-i","-X", "POST" ,"-H", "Content-Type:application/json" ,"-d", json.dumps(_json_payload), get_setting_value('APPRISE_HOST')], stdout=subprocess.PIPE, stderr=subprocess.STDOUT) stdout, stderr = p.communicate() # write stdout and stderr into .log files for debugging if needed diff --git a/pialert/__main__.py b/pialert/__main__.py index fd01c6b6..8a07f430 100755 --- a/pialert/__main__.py +++ b/pialert/__main__.py @@ -154,7 +154,7 @@ def main (): notiStructure = get_notifications(db) # Write the notifications into the DB - notification = Notification_obj(db) + notification = Notification_obj(db) hasNotification = notification.create(notiStructure.json, notiStructure.text, notiStructure.html, "") # run all enabled publisher gateways @@ -162,6 +162,21 @@ def main (): pluginsState = run_plugin_scripts(db, 'on_notification', pluginsState) notification.setAllProcessed() + # Clean Pending Alert Events + sql.execute ("""UPDATE Devices SET dev_LastNotification = ? + WHERE dev_MAC IN ( + SELECT eve_MAC FROM Events + WHERE eve_PendingAlertEmail = 1 + ) + """, (timeNowTZ(),) ) + sql.execute ("""UPDATE Events SET eve_PendingAlertEmail = 0 + WHERE eve_PendingAlertEmail = 1""") + + # clear plugin events + sql.execute ("DELETE FROM Plugins_Events") + + # DEBUG - print number of rows updated + mylog('minimal', ['[Notification] Notifications changes: ', sql.rowcount]) # Commit SQL db.commitDB() diff --git a/pialert/reporting.py b/pialert/reporting.py index 7cf512a4..14d84b1d 100755 --- a/pialert/reporting.py +++ b/pialert/reporting.py @@ -315,22 +315,6 @@ def get_notifications (db): # else : # mylog('verbose', ['[Notification] No changes to report']) - # # Clean Pending Alert Events - # sql.execute ("""UPDATE Devices SET dev_LastNotification = ? - # WHERE dev_MAC IN (SELECT eve_MAC FROM Events - # WHERE eve_PendingAlertEmail = 1) - # """, (datetime.datetime.now(conf.tz),) ) - # sql.execute ("""UPDATE Events SET eve_PendingAlertEmail = 0 - # WHERE eve_PendingAlertEmail = 1""") - - # # clear plugin events - # sql.execute ("DELETE FROM Plugins_Events") - - # # DEBUG - print number of rows updated - # mylog('minimal', ['[Notification] Notifications changes: ', sql.rowcount]) - - # # Commit changes - # db.commitDB() #------------------------------------------------------------------------------- From 79c47015f450a5fb499667d0da409abca98521ee Mon Sep 17 00:00:00 2001 From: Jokob-sk Date: Sun, 8 Oct 2023 11:15:10 +1100 Subject: [PATCH 07/13] Notification rework v0.5 --- front/plugins/README.md | 4 +- front/plugins/_publisher_apprise/apprise.py | 14 ++--- front/plugins/_publisher_apprise/config.json | 2 +- pialert/helper.py | 40 ++++++++---- pialert/plugin.py | 64 +++++++++++--------- pialert/plugin_utils.py | 40 +++++++----- pialert/reporting.py | 28 ++++----- 7 files changed, 111 insertions(+), 81 deletions(-) diff --git a/front/plugins/README.md b/front/plugins/README.md index 69bf683a..53353fdc 100755 --- a/front/plugins/README.md +++ b/front/plugins/README.md @@ -542,8 +542,8 @@ Required attributes are: | `"name"` | Displayed on the Settings page. An array of localized strings. See Localized strings below. | | `"description"` | Displayed on the Settings page. An array of localized strings. See Localized strings below. | | (optional) `"events"` | Specifies whether to generate an execution button next to the input field of the setting. Supported values: | -| | - `test` | -| | - `run` | +| | - `"test"` - For notification plugins testing | +| | - `"run"` - Regular plugins testing | | (optional) `"override_value"` | Used to determine a user-defined override for the setting. Useful for template-based plugins, where you can choose to leave the current value or override it with the value defined in the setting. (Work in progress) | | (optional) `"events"` | Used to trigger the plugin. Usually used on the `RUN` setting. Not fully tested in all scenarios. Will show a play button next to the setting. After clicking, an event is generated for the backend in the `Parameters` database table to process the front-end event on the next run. | diff --git a/front/plugins/_publisher_apprise/apprise.py b/front/plugins/_publisher_apprise/apprise.py index 85a61f38..2feb151f 100755 --- a/front/plugins/_publisher_apprise/apprise.py +++ b/front/plugins/_publisher_apprise/apprise.py @@ -43,7 +43,7 @@ def main(): plugin_objects = Plugin_Objects(RESULT_FILE) # Create a Notification_obj instance - Notification_obj(db) + notifications = Notification_obj(db) # Retrieve new notifications new_notifications = notifications.getNew() @@ -86,15 +86,15 @@ def send(html, text): # truncate size if get_setting_value('APPRISE_PAYLOAD') == 'html': - if len(msg.html) > limit: - payloadData = msg.html[:limit] + "

      (text was truncated)

      " + if len(html) > limit: + payloadData = html[:limit] + "

      (text was truncated)

      " else: - payloadData = msg.html + payloadData = html if get_setting_value('APPRISE_PAYLOAD') == 'text': - if len(msg.text) > limit: - payloadData = msg.text[:limit] + " (text was truncated)" + if len(text) > limit: + payloadData = text[:limit] + " (text was truncated)" else: - payloadData = msg.text + payloadData = text # Define Apprise compatible payload (https://github.com/caronc/apprise-api#stateless-solution) diff --git a/front/plugins/_publisher_apprise/config.json b/front/plugins/_publisher_apprise/config.json index 7443919a..1d890b7c 100755 --- a/front/plugins/_publisher_apprise/config.json +++ b/front/plugins/_publisher_apprise/config.json @@ -254,7 +254,7 @@ "settings":[ { "function": "RUN", - "events": ["run"], + "events": ["test"], "type": "text.select", "default_value":"disabled", "options": ["disabled", "on_notification" ], diff --git a/pialert/helper.py b/pialert/helper.py index 1207ec92..19e1aabe 100755 --- a/pialert/helper.py +++ b/pialert/helper.py @@ -243,18 +243,32 @@ def write_file(pPath, pText): #------------------------------------------------------------------------------- # Return whole setting touple def get_setting(key): - result = None - # index order: key, name, desc, inputtype, options, regex, result, group, events - for set in conf.mySettings: - if set[0] == key: - result = set - - if result is None: - mylog('minimal', [' Error - setting_missing - Setting not found for key: ', key]) - mylog('minimal', [' Error - logging the settings into file: ', logPath + '/setting_missing.json']) - write_file (logPath + '/setting_missing.json', json.dumps({ 'data' : conf.mySettings})) - return result + settingsFile = apiPath + '/table_settings.json' + + try: + with open(settingsFile, 'r') as json_file: + + data = json.load(json_file) + + # if not isinstance(data, list): + # mylog('minimal', [f' [Settings] Data is not a list of dictionaries (file: {settingsFile})']) + + for item in data.get("data",[]): + if item.get("Code_Name") == key: + return item + + mylog('minimal', [f'[Settings] Error - setting_missing - Setting not found for key: {key} in file {settingsFile}']) + + return None + + except (FileNotFoundError, json.JSONDecodeError, ValueError) as e: + # Handle the case when the file is not found, JSON decoding fails, or data is not in the expected format + mylog('minimal', [f'[Settings] Error - JSONDecodeError or FileNotFoundError for file {settingsFile}']) + + return None + + #------------------------------------------------------------------------------- # Return setting value @@ -264,8 +278,8 @@ def get_setting_value(key): if get_setting(key) is not None: - setVal = set[6] # setting value - setTyp = set[3] # setting type + setVal = set["Value"] # setting value + setTyp = set["Type"] # setting type return setVal diff --git a/pialert/plugin.py b/pialert/plugin.py index 70557dcd..5e77e61a 100755 --- a/pialert/plugin.py +++ b/pialert/plugin.py @@ -13,7 +13,7 @@ from const import pluginsPath, logPath from logger import mylog from helper import timeNowTZ, updateState, get_file_content, write_file, get_setting, get_setting_value from api import update_api -from plugin_utils import logEventStatusCounts, get_plugin_string, get_plugin_setting, print_plugin_info, flatten_array, combine_plugin_objects, resolve_wildcards_arr, get_plugin_setting_value, handle_empty, custom_plugin_decoder +from plugin_utils import logEventStatusCounts, get_plugin_string, get_plugin_setting, print_plugin_info, list_to_csv, combine_plugin_objects, resolve_wildcards_arr, get_plugin_setting_value, handle_empty, custom_plugin_decoder #------------------------------------------------------------------------------- @@ -27,8 +27,8 @@ class plugin_param: inputValue = get_setting(param["value"]) if inputValue != None: - setVal = inputValue[6] # setting value - setTyp = inputValue[3] # setting type + setVal = inputValue["Value"] # setting value + setTyp = inputValue["Type"] # setting type noConversion = ['text', 'string', 'integer', 'boolean', 'password', 'readonly', 'integer.select', 'text.select', 'integer.checkbox' ] arrayConversion = ['text.multiselect', 'list', 'subnets'] @@ -45,11 +45,8 @@ class plugin_param: elif setTyp in arrayConversion: # make them safely passable to a python or linux script - resolved = flatten_array(setVal) + resolved = list_to_csv(setVal) - elif setTyp in arrayConversionBase64: - # make them safely passable to a python or linux script by converting them to a base64 string if necessary (if the arg contains spaces) - resolved = flatten_array(setVal) else: for item in jsonConversion: if setTyp.endswith(item): @@ -66,7 +63,7 @@ class plugin_param: paramValuesCount = len(inputValue) # make them safely passable to a python or linux script - resolved = flatten_array(inputValue) + resolved = list_to_csv(inputValue) mylog('debug', f'[Plugins] Resolved value: {resolved}') @@ -750,7 +747,7 @@ def check_and_run_user_event(db, pluginsState): return pluginsState if event == 'test': - handle_test(param) + pluginsState = handle_test(param, db, pluginsState) if event == 'run': pluginsState = handle_run(param, db, pluginsState) @@ -778,34 +775,41 @@ def handle_run(runType, db, pluginsState): #------------------------------------------------------------------------------- -def handle_test(testType): +def handle_test(runType, db, pluginsState): mylog('minimal', ['[', timeNowTZ(), '] START Test: ', testType]) + + # TODO finish - # Open text sample - sample_txt = get_file_content(pialertPath + '/back/report_sample.txt') + # # Open text sample + # sample_txt = get_file_content(pialertPath + '/back/report_sample.txt') - # Open html sample - sample_html = get_file_content(pialertPath + '/back/report_sample.html') + # # Open html sample + # sample_html = get_file_content(pialertPath + '/back/report_sample.html') - # Open json sample and get only the payload part - sample_json_payload = json.loads(get_file_content(pialertPath + '/back/webhook_json_sample.json'))[0]["body"]["attachments"][0]["text"] + # # Open json sample and get only the payload part + # sample_json_payload = json.loads(get_file_content(pialertPath + '/back/webhook_json_sample.json'))[0]["body"]["attachments"][0]["text"] - sample_msg = noti_obj(sample_json_payload, sample_txt, sample_html, "test_sample") + # sample_msg = noti_obj(sample_json_payload, sample_txt, sample_html, "test_sample") + + + pluginsState = handle_run(param, db, pluginsState) - if testType == 'Email': - send_email(sample_msg) - elif testType == 'Webhooks': - send_webhook (sample_msg) - elif testType == 'Apprise': - send_apprise (sample_msg) - elif testType == 'NTFY': - send_ntfy (sample_msg) - elif testType == 'PUSHSAFER': - send_pushsafer (sample_msg) - else: - mylog('none', ['[Test Publishers] No test matches: ', testType]) + # if testType == 'Email': + # send_email(sample_msg) + # elif testType == 'Webhooks': + # send_webhook (sample_msg) + # elif testType == 'Apprise': + # send_apprise (sample_msg) + # elif testType == 'NTFY': + # send_ntfy (sample_msg) + # elif testType == 'PUSHSAFER': + # send_pushsafer (sample_msg) + # else: + # mylog('none', ['[Test Publishers] No test matches: ', testType]) - mylog('minimal', ['[Test Publishers] END Test: ', testType]) + # mylog('minimal', ['[Test Publishers] END Test: ', testType]) + + return pluginsState diff --git a/pialert/plugin_utils.py b/pialert/plugin_utils.py index ed85cf8d..258f76bc 100755 --- a/pialert/plugin_utils.py +++ b/pialert/plugin_utils.py @@ -71,30 +71,42 @@ def get_plugin_string(props, el): #------------------------------------------------------------------------------- -def flatten_array(arr): +# generates a comma separated list of values from a list (or a string representing a list) +def list_to_csv(arr): tmp = '' arrayItemStr = '' - mylog('debug', '[Plugins] Flattening the below array') - + mylog('debug', '[Plugins] Flattening the below array') mylog('debug', arr) - for arrayItem in arr: - # only one column flattening is supported - if isinstance(arrayItem, list): - arrayItemStr = str(arrayItem[0]).replace("'", '') # removing single quotes - not allowed - else: - # is string already - arrayItemStr = arrayItem + + mylog('debug', f'[Plugins] isinstance(arr, list) : {isinstance(arr, list)}') + mylog('debug', f'[Plugins] isinstance(arr, str) : {isinstance(arr, str)}') + + if isinstance(arr, str): + return arr.replace('[','').replace(']','').replace("'", '') # removing brackets and single quotes (not allowed) + + elif isinstance(arr, list): + for arrayItem in arr: + # only one column flattening is supported + if isinstance(arrayItem, list): + arrayItemStr = str(arrayItem[0]).replace("'", '') # removing single quotes - not allowed + else: + # is string already + arrayItemStr = arrayItem - tmp += f'{arrayItemStr},' + tmp += f'{arrayItemStr},' - tmp = tmp[:-1] # Remove last comma ',' + tmp = tmp[:-1] # Remove last comma ',' - mylog('debug', f'[Plugins] Flattened array: {tmp}') + mylog('debug', f'[Plugins] Flattened array: {tmp}') + + return tmp + + else: + mylog('none', f'[Plugins] ERROR Could not convert array: {arr}') - return tmp diff --git a/pialert/reporting.py b/pialert/reporting.py index 14d84b1d..3ed380a8 100755 --- a/pialert/reporting.py +++ b/pialert/reporting.py @@ -317,26 +317,26 @@ def get_notifications (db): -#------------------------------------------------------------------------------- -def check_config(service): +# #------------------------------------------------------------------------------- +# def check_config(service): - if service == 'email': - return email_check_config() +# if service == 'email': +# return email_check_config() - if service == 'apprise': - return apprise_check_config() +# if service == 'apprise': +# return apprise_check_config() - if service == 'webhook': - return webhook_check_config() +# if service == 'webhook': +# return webhook_check_config() - if service == 'ntfy': - return ntfy_check_config () +# if service == 'ntfy': +# return ntfy_check_config () - if service == 'pushsafer': - return pushsafer_check_config() +# if service == 'pushsafer': +# return pushsafer_check_config() - if service == 'mqtt': - return mqtt_check_config() +# if service == 'mqtt': +# return mqtt_check_config() #------------------------------------------------------------------------------- # Replacing table headers From be4e0acdfc869f1b4ada89efb8e80671acbb91dd Mon Sep 17 00:00:00 2001 From: Jokob-sk Date: Sun, 8 Oct 2023 14:52:22 +1100 Subject: [PATCH 08/13] Notification rework - Apprise v1 - working --- back/report_template.txt | 6 +- back/webhook_json_sample.json | 156 +++++++++---------- front/plugins/README.md | 3 +- front/plugins/_publisher_apprise/config.json | 4 +- front/pluginsCore.php | 5 + pialert/__main__.py | 6 +- pialert/helper.py | 32 ++-- pialert/notification.py | 25 +-- pialert/plugin.py | 55 +++---- pialert/plugin_utils.py | 19 +-- pialert/reporting.py | 23 +-- 11 files changed, 152 insertions(+), 182 deletions(-) diff --git a/back/report_template.txt b/back/report_template.txt index 85f8575a..80c9562c 100755 --- a/back/report_template.txt +++ b/back/report_template.txt @@ -1,7 +1,7 @@ Report Date: Server: - - - + + + diff --git a/back/webhook_json_sample.json b/back/webhook_json_sample.json index 940630d0..a782fa16 100755 --- a/back/webhook_json_sample.json +++ b/back/webhook_json_sample.json @@ -1,90 +1,76 @@ [ - { - "headers": { - "host": "192.168.1.82:5678", - "user-agent": "curl/7.74.0", - "accept": "*/*", - "content-type": "application/json", - "content-length": "872" - }, - "params": {}, - "query": {}, - "body": { - "username": "Pi.Alert", - "text": "There are new notifications", - "attachments": [ - { - "title": "Pi.Alert Notifications", - "title_link": "", - "text": { - "internet": [], - "new_devices": [{ - "MAC": "74:ac:74:ac:74:ac", - "Datetime": "2023-01-30 22:15:09", - "IP": "192.168.1.1", - "Event Type": "New Device", - "Device name": "(name not found)", - "Comments": null, - "Device Vendor": null - }], - "down_devices": [], - "events": [{ - "MAC": "74:ac:74:ac:74:ac", - "Datetime": "2023-01-30 22:15:09", - "IP": "192.168.1.92", - "Event Type": "Disconnected", - "Device name": "(name not found)", - "Comments": null, - "Device Vendor": null - }, { - "MAC": "74:ac:74:ac:74:ac", - "Datetime": "2023-01-30 22:15:09", - "IP": "192.168.1.150", - "Event Type": "Disconnected", - "Device name": "(name not found)", - "Comments": null, - "Device Vendor": null - }], - "ports": [{ - "new": { - "Name": "New device", - "MAC": "74:ac:74:ac:74:ac", - "Port": "22/tcp", - "State": "open", - "Service": "ssh", - "Extra": "" - } - }, { - "new": { - "Name": "New device", - "MAC": "74:ac:74:ac:74:ac", - "Port": "53/tcp", - "State": "open", - "Service": "domain", - "Extra": "" - } - }, { - "new": { - "Name": "New device", - "MAC": "74:ac:74:ac:74:ac", - "Port": "80/tcp", - "State": "open", - "Service": "http", - "Extra": "" - } - }, { - "new": { - "Name": "New device", - "MAC": "74:ac:74:ac:74:ac", - "Port": "443/tcp", - "State": "open", - "Service": "https", - "Extra": "" - } - }] - } - } + { + "headers": { + "host": "192.168.1.82:5678", + "user-agent": "curl/7.74.0", + "accept": "*/*", + "content-type": "application/json", + "content-length": "872" + }, + "params": {}, + "query": {}, + "body": { + "username": "Pi.Alert", + "text": "There are new notifications", + "attachments": [ + { + "title": "Pi.Alert Notifications", + "title_link": "", + "text": { + "internet": [], + "new_devices": [ + { + "MAC": "74:ac:74:ac:74:ac", + "Datetime": "2023-01-30 22:15:09", + "IP": "192.168.1.1", + "Event Type": "New Device", + "Device name": "(name not found)", + "Comments": null, + "Device Vendor": null + } + ], + "down_devices": [], + "events": [ + { + "MAC": "74:ac:74:ac:74:ac", + "Datetime": "2023-01-30 22:15:09", + "IP": "192.168.1.92", + "Event Type": "Disconnected", + "Device name": "(name not found)", + "Comments": null, + "Device Vendor": null + }, + { + "MAC": "74:ac:74:ac:74:ac", + "Datetime": "2023-01-30 22:15:09", + "IP": "192.168.1.150", + "Event Type": "Disconnected", + "Device name": "(name not found)", + "Comments": null, + "Device Vendor": null + } + ], + "plugins": [ + { + "Index": 138, + "Plugin": "INTRSPD", + "Object_PrimaryID": "Speedtest", + "Object_SecondaryID": "2023-10-08 02:01:16+02:00", + "DateTimeCreated": "2023-10-08 02:01:16", + "DateTimeChanged": "2023-10-08 02:32:15", + "Watched_Value1": "-1", + "Watched_Value2": "-1", + "Watched_Value3": "null", + "Watched_Value4": "null", + "Status": "missing-in-last-scan", + "Extra": "null", + "UserData": "null", + "ForeignKey": "null" + } ] + } } + ] } + } ] \ No newline at end of file diff --git a/front/plugins/README.md b/front/plugins/README.md index 53353fdc..9764f70d 100755 --- a/front/plugins/README.md +++ b/front/plugins/README.md @@ -619,7 +619,8 @@ The UI will adjust how columns are displayed in the UI based on the resolvers de | Supported Types | Description | | -------------- | ----------- | | `label` | Displays a column only. | -| `text` | Makes a column editable, and a save icon is displayed next to it. See below for information on `threshold`, `replace`. | +| `textarea_readonly` | Generates a read only text area and cleans up the text to display it somewhat formatted with new lines preserved. | +| See below for information on `threshold`, `replace`. | | | | | | `options` Property | Used in conjunction with types like `threshold`, `replace`, `regex`. | | `threshold` | The `options` array contains objects ordered from the lowest `maximum` to the highest. The corresponding `hexColor` is used for the value background color if it's less than the specified `maximum` but more than the previous one in the `options` array. | diff --git a/front/plugins/_publisher_apprise/config.json b/front/plugins/_publisher_apprise/config.json index 1d890b7c..99b05b97 100755 --- a/front/plugins/_publisher_apprise/config.json +++ b/front/plugins/_publisher_apprise/config.json @@ -137,9 +137,9 @@ }, { "column": "Watched_Value2", - "css_classes": "col-sm-2", + "css_classes": "col-sm-8", "show": true, - "type": "label", + "type": "textarea_readonly", "default_value":"", "options": [], "localized": ["name"], diff --git a/front/pluginsCore.php b/front/pluginsCore.php index e30f986f..e0d6fdb6 100755 --- a/front/pluginsCore.php +++ b/front/pluginsCore.php @@ -89,6 +89,11 @@ function processColumnValue(dbColumnDef, value, index, type) { case 'label': value = `${value}`; break; + case 'textarea_readonly': + value = ``; + break; case 'textbox_save': value = value == 'null' ? '' : value; // hide 'null' values diff --git a/pialert/__main__.py b/pialert/__main__.py index 8a07f430..6eedc75e 100755 --- a/pialert/__main__.py +++ b/pialert/__main__.py @@ -155,10 +155,10 @@ def main (): # Write the notifications into the DB notification = Notification_obj(db) - hasNotification = notification.create(notiStructure.json, notiStructure.text, notiStructure.html, "") + notificationObj = notification.create(notiStructure.json, notiStructure.text, notiStructure.html, "") # run all enabled publisher gateways - if hasNotification: + if notificationObj.HasNotifications: pluginsState = run_plugin_scripts(db, 'on_notification', pluginsState) notification.setAllProcessed() @@ -177,6 +177,8 @@ def main (): # DEBUG - print number of rows updated mylog('minimal', ['[Notification] Notifications changes: ', sql.rowcount]) + else: + mylog('verbose', ['[Notification] No changes to report']) # Commit SQL db.commitDB() diff --git a/pialert/helper.py b/pialert/helper.py index 19e1aabe..4a36c8b5 100755 --- a/pialert/helper.py +++ b/pialert/helper.py @@ -244,27 +244,24 @@ def write_file(pPath, pText): # Return whole setting touple def get_setting(key): - settingsFile = apiPath + '/table_settings.json' + settingsFile = apiPath + 'table_settings.json' try: with open(settingsFile, 'r') as json_file: data = json.load(json_file) - # if not isinstance(data, list): - # mylog('minimal', [f' [Settings] Data is not a list of dictionaries (file: {settingsFile})']) - for item in data.get("data",[]): if item.get("Code_Name") == key: return item - mylog('minimal', [f'[Settings] Error - setting_missing - Setting not found for key: {key} in file {settingsFile}']) + mylog('debug', [f'[Settings] Error - setting_missing - Setting not found for key: {key} in file {settingsFile}']) return None except (FileNotFoundError, json.JSONDecodeError, ValueError) as e: # Handle the case when the file is not found, JSON decoding fails, or data is not in the expected format - mylog('minimal', [f'[Settings] Error - JSONDecodeError or FileNotFoundError for file {settingsFile}']) + mylog('none', [f'[Settings] Error - JSONDecodeError or FileNotFoundError for file {settingsFile}']) return None @@ -274,19 +271,32 @@ def get_setting(key): # Return setting value def get_setting_value(key): - set = get_setting(key) + setting = get_setting(key) - if get_setting(key) is not None: + if setting is not None: - setVal = set["Value"] # setting value - setTyp = set["Type"] # setting type + set_value = setting["Value"] # Setting value + set_type = setting["Type"] # Setting type - return setVal + # Handle different types of settings + if set_type in ['text', 'string', 'password', 'readonly', 'text.select']: + return str(set_value) + elif set_type in ['boolean', 'integer.checkbox']: + return bool(set_value) + elif set_type in ['integer.select', 'integer']: + return int(set_value) + elif set_type in ['text.multiselect', 'list', 'subnets']: + # Assuming set_value is a list in this case + return set_value + elif set_type == '.template': + # Assuming set_value is a JSON object in this case + return json.loads(set_value) return '' + #------------------------------------------------------------------------------- # IP validation methods #------------------------------------------------------------------------------- diff --git a/pialert/notification.py b/pialert/notification.py index b9d8a052..82c474a6 100644 --- a/pialert/notification.py +++ b/pialert/notification.py @@ -40,11 +40,8 @@ class Notification_obj: # Check if nothing to report, end if JSON["internet"] == [] and JSON["new_devices"] == [] and JSON["down_devices"] == [] and JSON["events"] == [] and JSON["plugins"] == []: self.HasNotifications = False - # end if nothing to report - return self.HasNotifications - - # continue and save into DB if notifications available - self.HasNotifications = True + else: + self.HasNotifications = True self.GUID = str(uuid.uuid4()) self.DateTimeCreated = timeNowTZ() @@ -56,9 +53,10 @@ class Notification_obj: self.PublishedVia = "" self.Extra = Extra - self.upsert() + if self.HasNotifications: + self.upsert() - return self.HasNotifications + return self # Only updates the status def updateStatus(self, newStatus): @@ -69,9 +67,7 @@ class Notification_obj: def updatePublishedVia(self, newPublishedVia): self.PublishedVia = newPublishedVia self.DateTimePushed = timeNowTZ() - self.upsert() - - # TODO Index vs hash to minimize SQL calls, finish CRUD operations, expose via API, use API in plugins + self.upsert() # create or update a notification def upsert(self): @@ -82,6 +78,15 @@ class Notification_obj: self.save() + # Remove notification object by GUID + def remove(self, GUID): + # Execute an SQL query to delete the notification with the specified GUID + self.db.sql.execute(""" + DELETE FROM Notifications + WHERE GUID = ? + """, (GUID,)) + self.save() + # Get all with the "new" status def getNew(self): self.db.sql.execute(""" diff --git a/pialert/plugin.py b/pialert/plugin.py index 5e77e61a..e0a2f8f1 100755 --- a/pialert/plugin.py +++ b/pialert/plugin.py @@ -9,11 +9,12 @@ from collections import namedtuple # pialert modules import conf -from const import pluginsPath, logPath +from const import pluginsPath, logPath, pialertPath from logger import mylog from helper import timeNowTZ, updateState, get_file_content, write_file, get_setting, get_setting_value from api import update_api -from plugin_utils import logEventStatusCounts, get_plugin_string, get_plugin_setting, print_plugin_info, list_to_csv, combine_plugin_objects, resolve_wildcards_arr, get_plugin_setting_value, handle_empty, custom_plugin_decoder +from plugin_utils import logEventStatusCounts, get_plugin_string, get_plugin_setting, print_plugin_info, list_to_csv, combine_plugin_objects, resolve_wildcards_arr, handle_empty, custom_plugin_decoder +from notification import Notification_obj #------------------------------------------------------------------------------- @@ -484,10 +485,7 @@ def process_plugin_events(db, plugin, pluginsState, plugEventsArr): history_to_insert = [] objects_to_update = [] - statuses_to_report_on = get_plugin_setting_value(plugin, "REPORT_ON") - - mylog('debug', ['[Plugins] statuses_to_report_on: ', statuses_to_report_on]) - + for plugObj in pluginObjects: # keep old createdTime time if the plugObj already was created before createdTime = plugObj.changed if plugObj.status == 'new' else plugObj.created @@ -505,6 +503,8 @@ def process_plugin_events(db, plugin, pluginsState, plugEventsArr): objects_to_update.append(values + (plugObj.index,)) # Include index for UPDATE # only generate events that we want to be notified on + statuses_to_report_on = get_setting_value(plugObj.pluginPref + "_REPORT_ON") + if plugObj.status in statuses_to_report_on: events_to_insert.append(values) @@ -777,39 +777,24 @@ def handle_run(runType, db, pluginsState): #------------------------------------------------------------------------------- def handle_test(runType, db, pluginsState): - mylog('minimal', ['[', timeNowTZ(), '] START Test: ', testType]) + mylog('minimal', ['[', timeNowTZ(), '] [Test] START Test: ', runType]) - # TODO finish + # Prepare test samples + sample_txt = get_file_content(pialertPath + '/back/report_sample.txt') + sample_html = get_file_content(pialertPath + '/back/report_sample.html') + sample_json = json.loads(get_file_content(pialertPath + '/back/webhook_json_sample.json'))[0]["body"]["attachments"][0]["text"] + + # Create fake notification + notification = Notification_obj(db) + notificationObj = notification.create(sample_json, sample_txt, sample_html, "") - # # Open text sample - # sample_txt = get_file_content(pialertPath + '/back/report_sample.txt') + # Run test + pluginsState = handle_run(runType, db, pluginsState) - # # Open html sample - # sample_html = get_file_content(pialertPath + '/back/report_sample.html') + # Remove sample notification + notificationObj.remove(notificationObj.GUID) - # # Open json sample and get only the payload part - # sample_json_payload = json.loads(get_file_content(pialertPath + '/back/webhook_json_sample.json'))[0]["body"]["attachments"][0]["text"] - - # sample_msg = noti_obj(sample_json_payload, sample_txt, sample_html, "test_sample") - - - pluginsState = handle_run(param, db, pluginsState) - - - # if testType == 'Email': - # send_email(sample_msg) - # elif testType == 'Webhooks': - # send_webhook (sample_msg) - # elif testType == 'Apprise': - # send_apprise (sample_msg) - # elif testType == 'NTFY': - # send_ntfy (sample_msg) - # elif testType == 'PUSHSAFER': - # send_pushsafer (sample_msg) - # else: - # mylog('none', ['[Test Publishers] No test matches: ', testType]) - - # mylog('minimal', ['[Test Publishers] END Test: ', testType]) + mylog('minimal', ['[Test] END Test: ', runType]) return pluginsState diff --git a/pialert/plugin_utils.py b/pialert/plugin_utils.py index 258f76bc..fc914353 100755 --- a/pialert/plugin_utils.py +++ b/pialert/plugin_utils.py @@ -77,11 +77,8 @@ def list_to_csv(arr): arrayItemStr = '' mylog('debug', '[Plugins] Flattening the below array') - mylog('debug', arr) - - - mylog('debug', f'[Plugins] isinstance(arr, list) : {isinstance(arr, list)}') - mylog('debug', f'[Plugins] isinstance(arr, str) : {isinstance(arr, str)}') + mylog('debug', arr) + mylog('debug', f'[Plugins] isinstance(arr, list) : {isinstance(arr, list)} | isinstance(arr, str) : {isinstance(arr, str)}') if isinstance(arr, str): return arr.replace('[','').replace(']','').replace("'", '') # removing brackets and single quotes (not allowed) @@ -172,18 +169,6 @@ def get_plugins_configs(): return pluginsList # Return the list of plugin configurations - -#------------------------------------------------------------------------------- -# Gets the setting value -def get_plugin_setting_value(plugin, function_key): - - resultObj = get_plugin_setting(plugin, function_key) - - if resultObj != None: - return resultObj["value"] - - return None - #------------------------------------------------------------------------------- def custom_plugin_decoder(pluginDict): return namedtuple('X', pluginDict.keys())(*pluginDict.values()) diff --git a/pialert/reporting.py b/pialert/reporting.py index 3ed380a8..35e63803 100755 --- a/pialert/reporting.py +++ b/pialert/reporting.py @@ -192,7 +192,7 @@ def get_notifications (db): # collect "new_devices" for the webhook json json_new_devices = notiStruc.json["data"] - mail_text = mail_text.replace ('', notiStruc.text + '\n') + mail_text = mail_text.replace ('', notiStruc.text + '\n') mail_html = mail_html.replace ('', notiStruc.html) mylog('verbose', ['[Notification] New Devices sections done.']) @@ -208,7 +208,7 @@ def get_notifications (db): # collect "down_devices" for the webhook json json_down_devices = notiStruc.json["data"] - mail_text = mail_text.replace ('', notiStruc.text + '\n') + mail_text = mail_text.replace ('', notiStruc.text + '\n') mail_html = mail_html.replace ('', notiStruc.html) mylog('verbose', ['[Notification] Down Devices sections done.']) @@ -225,7 +225,7 @@ def get_notifications (db): # collect "events" for the webhook json json_events = notiStruc.json["data"] - mail_text = mail_text.replace ('', notiStruc.text + '\n') + mail_text = mail_text.replace ('', notiStruc.text + '\n') mail_html = mail_html.replace ('', notiStruc.html) mylog('verbose', ['[Notification] Events sections done.']) @@ -256,7 +256,7 @@ def get_notifications (db): final_text = removeDuplicateNewLines(mail_text) # Create clickable MAC links - final_html = generate_mac_links (mail_html, deviceUrl) + final_html = generate_mac_links (mail_html, deviceUrl) # Write output emails for debug write_file (logPath + '/report_output.json', json.dumps(final_json)) @@ -265,12 +265,7 @@ def get_notifications (db): return noti_obj(final_json, final_text, final_html) - # # Notify is something to report - # if hasNotifications: - - # mylog('none', ['[Notification] Changes detected, sending reports']) - - # msg = noti_obj(json_final, mail_text, mail_html) + # mylog('minimal', ['[Notification] Udating API files']) # send_api() @@ -281,12 +276,8 @@ def get_notifications (db): # send_email (msg ) # else : # mylog('verbose', ['[Notification] Skip email']) - # if conf.REPORT_APPRISE and check_config('apprise'): - # updateState("Send: Apprise") - # mylog('minimal', ['[Notification] Sending report by Apprise']) - # send_apprise (msg) - # else : - # mylog('verbose', ['[Notification] Skip Apprise']) + # + # if conf.REPORT_WEBHOOK and check_config('webhook'): # updateState("Send: Webhook") # mylog('minimal', ['[Notification] Sending report by Webhook']) From 43c57f00d02ec756863d129f5c1af1bf3aac419e Mon Sep 17 00:00:00 2001 From: Jokob-sk Date: Sun, 8 Oct 2023 16:28:15 +1100 Subject: [PATCH 09/13] Notification rework + docs + devDetails --- docs/ICONS.md | 4 +- docs/img/ICONS/device_icons_preview.gif | Bin 0 -> 114959 bytes front/css/pialert.css | 4 ++ front/deviceDetails.php | 15 ++--- front/plugins/_publisher_apprise/apprise.py | 3 - front/settings.php | 2 +- pialert/conf.py | 6 -- pialert/publishers/apprise.py | 58 -------------------- pialert/reporting.py | 28 +--------- 9 files changed, 17 insertions(+), 103 deletions(-) create mode 100755 docs/img/ICONS/device_icons_preview.gif delete mode 100755 pialert/publishers/apprise.py diff --git a/docs/ICONS.md b/docs/ICONS.md index 61ea561b..bceb56b8 100755 --- a/docs/ICONS.md +++ b/docs/ICONS.md @@ -1,6 +1,6 @@ ## Icons overview -Icons are used to visually distinguish devices in the app in most of the device listing tables and the [network tree](/docs/NETWORK_TREE.md). Currently only free [Font Awesome](https://fontawesome.com/search?o=r&m=free) icons (up-to v 6.4.0) are supported (I have an unblockable [sponsorship goal](https://github.com/sponsors/jokob-sk) to add the material design icon pack). +Icons are used to visually distinguish devices in the app in most of the device listing tables and the [network tree](/docs/NETWORK_TREE.md). Currently only free [Font Awesome](https://fontawesome.com/search?o=r&m=free) icons (up-to v 6.4.0) are supported. ![Raspberry Pi with a brand icon](/docs/img/ICONS/devices-icons.png) @@ -8,6 +8,8 @@ Icons are used to visually distinguish devices in the app in most of the device You can assign icons individually on each device in the Details tab. +![preview](/docs/img/ICONS/device_icons_preview.gif) + ![Raspberry Pi device details](/docs/img/ICONS/device-icon.png) - You can click into the `Icon` field or click the Pencil (2) icon in the above screenshot to enter any text. Only [free Font Awesome](https://fontawesome.com/search?o=r&m=free) icons in the following format will work: diff --git a/docs/img/ICONS/device_icons_preview.gif b/docs/img/ICONS/device_icons_preview.gif new file mode 100755 index 0000000000000000000000000000000000000000..01929ff39d44c4ac4e350fdc583c912a85d2622d GIT binary patch literal 114959 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 O$vK|I! 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

      w-MaM(t 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

      - +
      + + +
      @@ -156,6 +159,7 @@
      +
      @@ -172,6 +176,7 @@
      +
      @@ -227,6 +232,7 @@
      +
      @@ -1818,9 +1825,3 @@ function toggleNetworkConfiguration(disable) } - - \ No newline at end of file diff --git a/front/plugins/_publisher_apprise/apprise.py b/front/plugins/_publisher_apprise/apprise.py index 2feb151f..27b23fc8 100755 --- a/front/plugins/_publisher_apprise/apprise.py +++ b/front/plugins/_publisher_apprise/apprise.py @@ -36,9 +36,6 @@ def main(): db = DB() # instance of class DB db.open() - # parser = argparse.ArgumentParser(description='APPRISE publisher Plugin') - # values = parser.parse_args() - # Initialize the Plugin obj output file plugin_objects = Plugin_Objects(RESULT_FILE) diff --git a/front/settings.php b/front/settings.php index d2d48b85..a6dd7ee8 100755 --- a/front/settings.php +++ b/front/settings.php @@ -112,7 +112,7 @@ while ($row = $result -> fetchArray (SQLITE3_ASSOC)) { const settingGroups = []; const settingKeyOfLists = []; // core groups are the ones not generated by plugins - const settingCoreGroups = ['General', 'Email', 'Webhooks', 'Apprise', 'NTFY', 'PUSHSAFER', 'MQTT', 'DynDNS', 'API']; + const settingCoreGroups = ['General']; // Loop through the settingsArray and collect unique settingGroups diff --git a/pialert/conf.py b/pialert/conf.py index 266579cd..2078bf7d 100755 --- a/pialert/conf.py +++ b/pialert/conf.py @@ -70,12 +70,6 @@ WEBHOOK_PAYLOAD = 'json' WEBHOOK_REQUEST_METHOD = 'GET' WEBHOOK_SECRET = '' -# Apprise -REPORT_APPRISE = False -APPRISE_HOST = '' -APPRISE_URL = '' -APPRISE_PAYLOAD = 'html' - # NTFY REPORT_NTFY = False NTFY_HOST = 'https://ntfy.sh' diff --git a/pialert/publishers/apprise.py b/pialert/publishers/apprise.py deleted file mode 100755 index 6be9f0e5..00000000 --- a/pialert/publishers/apprise.py +++ /dev/null @@ -1,58 +0,0 @@ - -import json -import subprocess -import conf -from helper import noti_obj -from logger import logResult, mylog - -#------------------------------------------------------------------------------- -def check_config(): - if conf.APPRISE_URL == '' or conf.APPRISE_HOST == '': - mylog('none', ['[Check Config] Error: Apprise service not set up correctly. Check your pialert.conf APPRISE_* variables.']) - return False - else: - return True - -#------------------------------------------------------------------------------- -def send(msg: noti_obj): - html = msg.html - text = msg.text - - payloadData = '' - - # limit = 1024 * 1024 # 1MB limit (1024 bytes * 1024 bytes = 1MB) - limit = conf.APPRISE_SIZE - - # truncate size - if conf.APPRISE_PAYLOAD == 'html': - if len(msg.html) > limit: - payloadData = msg.html[:limit] + "

      (text was truncated)

      " - else: - payloadData = msg.html - if conf.APPRISE_PAYLOAD == 'text': - if len(msg.text) > limit: - payloadData = msg.text[:limit] + " (text was truncated)" - else: - payloadData = msg.text - - # Define Apprise compatible payload (https://github.com/caronc/apprise-api#stateless-solution) - - _json_payload = { - "urls": conf.APPRISE_URL, - "title": "Pi.Alert Notifications", - "format": conf.APPRISE_PAYLOAD, - "body": payloadData - } - - try: - # try runnning a subprocess - p = subprocess.Popen(["curl","-i","-X", "POST" ,"-H", "Content-Type:application/json" ,"-d", json.dumps(_json_payload), conf.APPRISE_HOST], stdout=subprocess.PIPE, stderr=subprocess.STDOUT) - stdout, stderr = p.communicate() - # write stdout and stderr into .log files for debugging if needed - logResult (stdout, stderr) # TO-DO should be changed to mylog - - # Log the stdout and stderr - mylog('debug', [stdout, stderr]) # TO-DO should be changed to mylog - except subprocess.CalledProcessError as e: - # An error occurred, handle it - mylog('none', [e.output]) \ No newline at end of file diff --git a/pialert/reporting.py b/pialert/reporting.py index 35e63803..8912d579 100755 --- a/pialert/reporting.py +++ b/pialert/reporting.py @@ -28,8 +28,6 @@ from publishers.email import (check_config as email_check_config, send as send_email ) from publishers.ntfy import (check_config as ntfy_check_config, send as send_ntfy ) -from publishers.apprise import (check_config as apprise_check_config, - send as send_apprise) from publishers.webhook import (check_config as webhook_check_config, send as send_webhook) from publishers.pushsafer import (check_config as pushsafer_check_config, @@ -263,9 +261,7 @@ def get_notifications (db): write_file (logPath + '/report_output.txt', final_text) write_file (logPath + '/report_output.html', final_html) - return noti_obj(final_json, final_text, final_html) - - + return noti_obj(final_json, final_text, final_html) # mylog('minimal', ['[Notification] Udating API files']) # send_api() @@ -307,28 +303,6 @@ def get_notifications (db): # mylog('verbose', ['[Notification] No changes to report']) - -# #------------------------------------------------------------------------------- -# def check_config(service): - -# if service == 'email': -# return email_check_config() - -# if service == 'apprise': -# return apprise_check_config() - -# if service == 'webhook': -# return webhook_check_config() - -# if service == 'ntfy': -# return ntfy_check_config () - -# if service == 'pushsafer': -# return pushsafer_check_config() - -# if service == 'mqtt': -# return mqtt_check_config() - #------------------------------------------------------------------------------- # Replacing table headers def format_table (html, thValue, props, newThValue = ''): From e4a64a11bd33b8fe4edd7bf2d1c1a6273eecd7de Mon Sep 17 00:00:00 2001 From: Jokob-sk Date: Sun, 8 Oct 2023 16:54:13 +1100 Subject: [PATCH 10/13] =?UTF-8?q?Notification=20rework=20-=20SMTP=20v0.1?= =?UTF-8?q?=20-=20WIP=F0=9F=91=B7=E2=80=8D=E2=99=82=EF=B8=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- front/plugins/_publisher_apprise/README.md | 8 + front/plugins/_publisher_apprise/apprise.py | 9 +- front/plugins/_publisher_email/README.md | 8 + front/plugins/_publisher_email/config.json | 424 ++++++++++++++++++++ front/plugins/_publisher_email/email.py | 160 ++++++++ front/plugins/internet_speedtest/script.py | 1 - 6 files changed, 605 insertions(+), 5 deletions(-) create mode 100755 front/plugins/_publisher_apprise/README.md create mode 100755 front/plugins/_publisher_email/README.md create mode 100755 front/plugins/_publisher_email/config.json create mode 100755 front/plugins/_publisher_email/email.py diff --git a/front/plugins/_publisher_apprise/README.md b/front/plugins/_publisher_apprise/README.md new file mode 100755 index 00000000..e32cc3f2 --- /dev/null +++ b/front/plugins/_publisher_apprise/README.md @@ -0,0 +1,8 @@ +## Overview + +[Apprise](front/plugins/arp_scan/README.md) is a notification gateway/publisher that allows you to push notifications to 80+ different services. + +### Usage + +- Go to settings and fill in relevant details. + diff --git a/front/plugins/_publisher_apprise/apprise.py b/front/plugins/_publisher_apprise/apprise.py index 27b23fc8..08f7ebd9 100755 --- a/front/plugins/_publisher_apprise/apprise.py +++ b/front/plugins/_publisher_apprise/apprise.py @@ -1,5 +1,4 @@ #!/usr/bin/env python -# Based on the work of https://github.com/leiweibau/Pi.Alert import json import subprocess @@ -23,13 +22,15 @@ from database import DB CUR_PATH = str(pathlib.Path(__file__).parent.resolve()) RESULT_FILE = os.path.join(CUR_PATH, 'last_result.log') +pluginName = 'APPRISE' + def main(): - mylog('verbose', ['[APPRISE](publisher) In script']) + mylog('verbose', [f'[{pluginName}](publisher) In script']) # Check if basic config settings supplied if check_config() == False: - mylog('none', ['[Check Config] Error: Apprise service not set up correctly. Check your pialert.conf APPRISE_* variables.']) + mylog('none', [f'[{pluginName}] Error: Publisher notification gateway not set up correctly. Check your pialert.conf {pluginName}_* variables.']) return # Create a database connection @@ -53,7 +54,7 @@ def main(): # Log result plugin_objects.add_object( - primaryId = 'APPRISE', + primaryId = pluginName, secondaryId = timeNowTZ(), watched1 = notification["GUID"], watched2 = result, diff --git a/front/plugins/_publisher_email/README.md b/front/plugins/_publisher_email/README.md new file mode 100755 index 00000000..e32cc3f2 --- /dev/null +++ b/front/plugins/_publisher_email/README.md @@ -0,0 +1,8 @@ +## Overview + +[Apprise](front/plugins/arp_scan/README.md) is a notification gateway/publisher that allows you to push notifications to 80+ different services. + +### Usage + +- Go to settings and fill in relevant details. + diff --git a/front/plugins/_publisher_email/config.json b/front/plugins/_publisher_email/config.json new file mode 100755 index 00000000..d073f963 --- /dev/null +++ b/front/plugins/_publisher_email/config.json @@ -0,0 +1,424 @@ +{ + "code_name": "_publisher_apprise", + "unique_prefix": "SMTP", + "enabled": true, + "data_source": "script", + "show_ui": true, + "localized": ["display_name", "description", "icon"], + "display_name" : [ + { + "language_code": "en_us", + "string" : "Apprise publisher" + }, + { + "language_code": "es_es", + "string" : "Habilitar Apprise" + } + ], + "icon":[{ + "language_code": "en_us", + "string" : "" + }], + "description": [ + { + "language_code": "en_us", + "string" : "A plugin to publish a notification via the Apprise gateway." + } + ], + "params" : [], + "database_column_definitions": + [ + { + "column": "Index", + "css_classes": "col-sm-2", + "show": false, + "type": "label", + "default_value":"", + "options": [], + "localized": ["name"], + "name":[{ + "language_code": "en_us", + "string" : "N/A" + }, + { + "language_code": "es_es", + "string" : "N/A" + }] + }, + { + "column": "Plugin", + "css_classes": "col-sm-2", + "show": false, + "type": "label", + "default_value":"", + "options": [], + "localized": ["name"], + "name":[{ + "language_code": "en_us", + "string" : "N/A" + }, + { + "language_code": "es_es", + "string" : "N/A" + }] + }, + { + "column": "Object_PrimaryID", + "css_classes": "col-sm-2", + "show": false, + "type": "url", + "default_value":"", + "options": [], + "localized": ["name"], + "name":[{ + "language_code": "en_us", + "string" : "N/A" + }] + }, + { + "column": "Object_SecondaryID", + "css_classes": "col-sm-2", + "show": false, + "type": "label", + "default_value":"", + "options": [], + "localized": ["name"], + "name":[{ + "language_code": "en_us", + "string" : "N/A" + }, + { + "language_code": "es_es", + "string" : "N/A" + }] + }, + { + "column": "DateTimeCreated", + "css_classes": "col-sm-2", + "show": true, + "type": "label", + "default_value":"", + "options": [], + "localized": ["name"], + "name":[{ + "language_code": "en_us", + "string" : "Sent when" + }] + }, + { + "column": "DateTimeChanged", + "css_classes": "col-sm-2", + "show": false, + "type": "label", + "default_value":"", + "options": [], + "localized": ["name"], + "name":[{ + "language_code": "en_us", + "string" : "Changed" + }, + { + "language_code": "es_es", + "string" : "Cambiado" + }] + }, + { + "column": "Watched_Value1", + "css_classes": "col-sm-2", + "show": true, + "type": "label", + "default_value":"", + "options": [], + "localized": ["name"], + "name":[{ + "language_code": "en_us", + "string" : "Notification GUID" + }] + }, + { + "column": "Watched_Value2", + "css_classes": "col-sm-8", + "show": true, + "type": "textarea_readonly", + "default_value":"", + "options": [], + "localized": ["name"], + "name":[{ + "language_code": "en_us", + "string" : "Result" + }] + }, + { + "column": "Watched_Value3", + "css_classes": "col-sm-2", + "show": false, + "type": "label", + "default_value":"", + "options": [], + "localized": ["name"], + "name":[{ + "language_code": "en_us", + "string" : "N/A" + }, + { + "language_code": "es_es", + "string" : "N/A" + }] + }, + { + "column": "Watched_Value4", + "css_classes": "col-sm-2", + "show": false, + "type": "label", + "default_value":"", + "options": [], + "localized": ["name"], + "name":[{ + "language_code": "en_us", + "string" : "N/A" + }, + { + "language_code": "es_es", + "string" : "N/A" + }] + }, + { + "column": "UserData", + "css_classes": "col-sm-2", + "show": false, + "type": "textbox_save", + "default_value":"", + "options": [], + "localized": ["name"], + "name":[{ + "language_code": "en_us", + "string" : "Comments" + }, + { + "language_code": "es_es", + "string" : "Comentarios" + }] + }, + { + "column": "Status", + "css_classes": "col-sm-1", + "show": false, + "type": "replace", + "default_value":"", + "options": [ + { + "equals": "watched-not-changed", + "replacement": "
      " + }, + { + "equals": "watched-changed", + "replacement": "
      " + }, + { + "equals": "new", + "replacement": "
      " + }, + { + "equals": "missing-in-last-scan", + "replacement": "
      " + } + ], + "localized": ["name"], + "name":[{ + "language_code": "en_us", + "string" : "Status" + }, + { + "language_code": "es_es", + "string" : "Estado" + }] + }, + { + "column": "Extra", + "css_classes": "col-sm-3", + "show": false, + "type": "label", + "default_value":"", + "options": [], + "localized": ["name"], + "name":[{ + "language_code": "en_us", + "string" : "Extra" + }, + { + "language_code": "es_es", + "string" : "Extra" + }] + } + ], + "settings":[ + { + "function": "RUN", + "events": ["test"], + "type": "text.select", + "default_value":"disabled", + "options": ["disabled", "on_notification" ], + "localized": ["name", "description"], + "name" :[{ + "language_code": "en_us", + "string" : "When to run" + }, + { + "language_code": "es_es", + "string" : "Cuando ejecuta" + }], + "description": [ + { + "language_code": "en_us", + "string" : "Enable sending notifications via Apprise." + }, + { + "language_code": "es_es", + "string" : "Habilitar el envío de notificaciones a través de Apprise." + } + ] + }, + { + "function": "CMD", + "type": "readonly", + "default_value":"python3 /home/pi/pialert/front/plugins/_publisher_email/email.py", + "options": [], + "localized": ["name", "description"], + "name" : [{ + "language_code": "en_us", + "string" : "Command" + }, + { + "language_code": "es_es", + "string" : "Comando" + }], + "description": [{ + "language_code": "en_us", + "string" : "Command to run" + }, + { + "language_code": "es_es", + "string" : "Comando a ejecutar" + }] + }, + { + "function": "RUN_TIMEOUT", + "type": "integer", + "default_value": 10, + "options": [], + "localized": ["name", "description"], + "name" : [{ + "language_code": "en_us", + "string" : "Run timeout" + }, + { + "language_code": "es_es", + "string" : "Tiempo de espera de ejecución" + }, + { + "language_code": "de_de", + "string" : "Wartezeit" + }], + "description": [{ + "language_code": "en_us", + "string" : "Maximum time in seconds to wait for the script to finish. If this time is exceeded the script is aborted." + }, + { + "language_code": "es_es", + "string" : "Tiempo máximo en segundos para esperar a que finalice el script. Si se supera este tiempo, el script se cancela." + }] + }, + { + "function": "HOST", + "type": "text", + "default_value": "", + "options": [], + "localized": ["name", "description"], + "name" : [{ + "language_code": "en_us", + "string" : "Apprise host URL" + }, + { + "language_code": "es_es", + "string" : "URL del host de Apprise" + }], + "description": [{ + "language_code": "en_us", + "string" : "Apprise host URL starting with http:// or https://. (do not forget to include /notify at the end)" + }, + { + "language_code": "es_es", + "string" : "URL del host de Apprise que comienza con http:// o https://. (no olvide incluir /notify al final)" + }] + }, + { + "function": "URL", + "type": "text", + "default_value": "", + "options": [], + "localized": ["name", "description"], + "name" : [{ + "language_code": "en_us", + "string" : "Apprise notification URL" + }, + { + "language_code": "es_es", + "string" : "URL de notificación de Apprise" + }], + "description": [{ + "language_code": "en_us", + "string" : "Apprise notification target URL. For example for Telegram it would be tgram://{bot_token}/{chat_id}." + }, + { + "language_code": "es_es", + "string" : "Informar de la URL de destino de la notificación. Por ejemplo, para Telegram sería tgram://{bot_token}/{chat_id}." + }] + }, + { + "function": "PAYLOAD", + "type": "text.select", + "default_value": "html", + "options": ["html", "text"], + "localized": ["name", "description"], + "name" : [{ + "language_code": "en_us", + "string" : "Payload type" + }, + { + "language_code": "es_es", + "string" : "Tipo de carga" + }], + "description": [{ + "language_code": "en_us", + "string" : "Select the payoad type sent to Apprise. For example html works well with emails, text with chat apps, such as Telegram." + }, + { + "language_code": "es_es", + "string" : "Seleccione el tipo de carga útil enviada a Apprise. Por ejemplo, html funciona bien con correos electrónicos, text con aplicaciones de chat, como Telegram." + }] + }, + { + "function": "SIZE", + "type": "integer", + "default_value": 1024, + "options": [], + "localized": ["name", "description"], + "name" : [{ + "language_code": "en_us", + "string" : "Max payload size" + }, + { + "language_code": "es_es", + "string" : "Tamaño máximo de carga útil" + }], + "description": [{ + "language_code": "en_us", + "string" : "The maximum size of the apprise payload as number of characters in the passed string. If above limit, it will be truncated and a (text was truncated) message is appended." + }, + { + "language_code": "es_es", + "string" : "El tamaño máximo de la carga útil de información como número de caracteres en la cadena pasada. Si supera el límite, se truncará y se agregará un mensaje (text was truncated)." + }] + } + ] +} diff --git a/front/plugins/_publisher_email/email.py b/front/plugins/_publisher_email/email.py new file mode 100755 index 00000000..25f1d638 --- /dev/null +++ b/front/plugins/_publisher_email/email.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python +import json +import subprocess +import argparse +import os +import pathlib +import sys +from datetime import datetime +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +import smtplib +import socket + +# Replace these paths with the actual paths to your Pi.Alert directories +sys.path.extend(["/home/pi/pialert/front/plugins", "/home/pi/pialert/pialert"]) + +# PiAlert modules +import conf +from plugin_helper import Plugin_Objects +from logger import mylog, append_line_to_file, print_log +from helper import timeNowTZ, noti_obj, get_setting_value, hide_email +from notification import Notification_obj +from database import DB + + +CUR_PATH = str(pathlib.Path(__file__).parent.resolve()) +RESULT_FILE = os.path.join(CUR_PATH, 'last_result.log') + +pluginName = 'SMTP' + +def main(): + + mylog('verbose', [f'[{pluginName}](publisher) In script']) + + # Check if basic config settings supplied + if check_config() == False: + mylog('none', [f'[{pluginName}] Error: Publisher notification gateway not set up correctly. Check your pialert.conf {pluginName}_* variables.']) + return + + # Create a database connection + db = DB() # instance of class DB + db.open() + + # Initialize the Plugin obj output file + plugin_objects = Plugin_Objects(RESULT_FILE) + + # Create a Notification_obj instance + notifications = Notification_obj(db) + + # Retrieve new notifications + new_notifications = notifications.getNew() + + # Process the new notifications + for notification in new_notifications: + + # Send notification + result = send(notification["HTML"], notification["Text"]) + + # Log result + plugin_objects.add_object( + primaryId = pluginName, + secondaryId = timeNowTZ(), + watched1 = notification["GUID"], + watched2 = result, + watched3 = 'null', + watched4 = 'null', + extra = 'null', + foreignKey = 'null' + ) + + plugin_objects.write_result_file() + +#------------------------------------------------------------------------------- +def check_config (): + if get_setting_value('SMTP_SERVER') == '' or get_setting_value('REPORT_FROM') == '' or get_setting_value('REPORT_TO') == '': + mylog('none', ['[Email Check Config] Error: Email service not set up correctly. Check your pialert.conf SMTP_*, REPORT_FROM and REPORT_TO variables.']) + return False + else: + return True + +#------------------------------------------------------------------------------- +def send(pHTML, pText): + + mylog('debug', [f'[{pluginName}] REPORT_TO: {hide_email(str(get_setting_value('REPORT_TO')))} SMTP_USER: {hide_email(str(get_setting_value('SMTP_USER')))}']) + + # Compose email + msg = MIMEMultipart('alternative') + msg['Subject'] = 'Pi.Alert Report' + msg['From'] = get_setting_value('REPORT_FROM') + msg['To'] = get_setting_value('REPORT_TO') + msg.attach (MIMEText (pText, 'plain')) + msg.attach (MIMEText (pHTML, 'html')) + + failedAt = '' + + failedAt = print_log ('SMTP try') + + try: + # Send mail + failedAt = print_log('Trying to open connection to ' + str(get_setting_value('SMTP_SERVER')) + ':' + str(get_setting_value('SMTP_PORT'))) + + # Set a timeout for the SMTP connection (in seconds) + smtp_timeout = 30 + + if get_setting_value('SMTP_FORCE_SSL'): + failedAt = print_log('SMTP_FORCE_SSL == True so using .SMTP_SSL()') + if get_setting_value('SMTP_PORT') == 0: + failedAt = print_log('SMTP_PORT == 0 so sending .SMTP_SSL(SMTP_SERVER)') + smtp_connection = smtplib.SMTP_SSL(get_setting_value('SMTP_SERVER')) + else: + failedAt = print_log('SMTP_PORT == 0 so sending .SMTP_SSL(SMTP_SERVER, SMTP_PORT)') + smtp_connection = smtplib.SMTP_SSL(get_setting_value('SMTP_SERVER'), get_setting_value('SMTP_PORT'), timeout=smtp_timeout) + + else: + failedAt = print_log('SMTP_FORCE_SSL == False so using .SMTP()') + if get_setting_value('SMTP_PORT') == 0: + failedAt = print_log('SMTP_PORT == 0 so sending .SMTP(SMTP_SERVER)') + smtp_connection = smtplib.SMTP (get_setting_value('SMTP_SERVER')) + else: + failedAt = print_log('SMTP_PORT == 0 so sending .SMTP(SMTP_SERVER, SMTP_PORT)') + smtp_connection = smtplib.SMTP (get_setting_value('SMTP_SERVER'), get_setting_value('SMTP_PORT')) + + failedAt = print_log('Setting SMTP debug level') + + # Log level set to debug of the communication between SMTP server and client + if get_setting_value('LOG_LEVEL') == 'debug': + smtp_connection.set_debuglevel(1) + + failedAt = print_log( 'Sending .ehlo()') + smtp_connection.ehlo() + + if not get_setting_value('SMTP_SKIP_TLS'): + failedAt = print_log('SMTP_SKIP_TLS == False so sending .starttls()') + smtp_connection.starttls() + failedAt = print_log('SMTP_SKIP_TLS == False so sending .ehlo()') + smtp_connection.ehlo() + if not get_setting_value('SMTP_SKIP_LOGIN'): + failedAt = print_log('SMTP_SKIP_LOGIN') == False so sending .login()') + smtp_connection.login (get_setting_value('SMTP_USER'), get_setting_value('SMTP_PASS')) + + failedAt = print_log('Sending .sendmail()') + smtp_connection.sendmail (get_setting_value('REPORT_FROM'), get_setting_value('REPORT_TO'), msg.as_string()) + smtp_connection.quit() + except smtplib.SMTPAuthenticationError as e: + mylog('none', [' ERROR: Failed at - ', failedAt]) + mylog('none', [' ERROR: Couldn\'t connect to the SMTP server (SMTPAuthenticationError), skipping Email (enable LOG_LEVEL=debug for more logging)']) + mylog('none', [' ERROR: ', str(e)]) + except smtplib.SMTPServerDisconnected as e: + mylog('none', [' ERROR: Failed at - ', failedAt]) + mylog('none', [' ERROR: Couldn\'t connect to the SMTP server (SMTPServerDisconnected), skipping Email (enable LOG_LEVEL=debug for more logging)']) + mylog('none', [' ERROR: ', str(e)]) + except socket.gaierror as e: + mylog('none', [' ERROR: Failed at - ', failedAt]) + mylog('none', [' ERROR: Could not resolve hostname (socket.gaierror), skipping Email (enable LOG_LEVEL=debug for more logging)']) + mylog('none', [' ERROR: ', str(e)]) + + mylog('debug', [f'[{pluginName}] Last executed - {str(failedAt)}']) + +if __name__ == '__main__': + sys.exit(main()) diff --git a/front/plugins/internet_speedtest/script.py b/front/plugins/internet_speedtest/script.py index 10b65cc0..8acadaba 100755 --- a/front/plugins/internet_speedtest/script.py +++ b/front/plugins/internet_speedtest/script.py @@ -1,5 +1,4 @@ #!/usr/bin/env python -# Based on the work of https://github.com/leiweibau/Pi.Alert import argparse import os From 1e693abfc49ce78397b8c808a362878fc5872a17 Mon Sep 17 00:00:00 2001 From: Jokob-sk Date: Sun, 8 Oct 2023 22:00:24 +1100 Subject: [PATCH 11/13] =?UTF-8?q?Notification=20rework=20-=20SMTP=20v0.2?= =?UTF-8?q?=20-=20WIP=F0=9F=91=B7=E2=80=8D=E2=99=82=EF=B8=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- front/php/templates/language/en_us.json | 26 +- front/plugins/_publisher_email/config.json | 1087 +++++++++++------- front/plugins/_publisher_email/ignore_plugin | 1 - pialert/__main__.py | 2 +- pialert/initialise.py | 72 +- pialert/publishers/email.py | 100 -- pialert/reporting.py | 2 - 7 files changed, 740 insertions(+), 550 deletions(-) delete mode 100755 front/plugins/_publisher_email/ignore_plugin delete mode 100755 pialert/publishers/email.py diff --git a/front/php/templates/language/en_us.json b/front/php/templates/language/en_us.json index 27620372..f1639a30 100755 --- a/front/php/templates/language/en_us.json +++ b/front/php/templates/language/en_us.json @@ -504,25 +504,13 @@ "Email_icon" : "", "REPORT_MAIL_name" : "Enable email", "REPORT_MAIL_description" : "If enabled an email is sent out with a list of changes you nove subscribed to. Please also fill out all remaining settings related to the SMTP setup below. If facing issues, set LOG_LEVEL to debug and check the error log.", - "SMTP_SERVER_name" : "SMTP server URL", - "SMTP_SERVER_description" : "The SMTP server host URL. For example smtp-relay.sendinblue.com. To use Gmail as an SMTP server follow this guide", - "SMTP_PORT_name" : "SMTP server PORT", - "SMTP_PORT_description" : "Port number used for the SMTP connection. Set to 0 if you do not want to use a port when connecting to the SMTP server.", - "SMTP_SKIP_LOGIN_name" : "Skip authentication", - "SMTP_SKIP_LOGIN_description" : "Do not use authentication when connecting to the SMTP server.", - "SMTP_USER_name" : "SMTP user", - "SMTP_USER_description" : "The user name used to login into the SMTP server (sometimes a full email address).", - "SMTP_PASS_name" : "SMTP password", - "SMTP_PASS_description" : "The SMTP server password. ", - "SMTP_SKIP_TLS_name" : "Do not use TLS", - "SMTP_SKIP_TLS_description" : "Disable TLS when connecting to your SMTP server.", - "SMTP_FORCE_SSL_name" : "Force SSL", - "SMTP_FORCE_SSL_description" : "Force SSL when connecting to your SMTP server.", - "SYSTEM_TITLE" : "System Information", - "REPORT_TO_name" : "Send email to", - "REPORT_TO_description" : "Email address to which the notification will be send to.", - "REPORT_FROM_name" : "Email subject", - "REPORT_FROM_description" : "Notification email subject line. Some SMTP servers need this to be an email.", + "SYSTEM_TITLE" : "System Information", + + "REPORT_TO_name" : "deprecated", + "REPORT_TO_description" : "deprecated", + "REPORT_FROM_name" : "deprecated", + "REPORT_FROM_description" : "deprecated", + "Webhooks_display_name" : "Webhooks", "Webhooks_icon" : "", "REPORT_WEBHOOK_name" : "Enable Webhooks", diff --git a/front/plugins/_publisher_email/config.json b/front/plugins/_publisher_email/config.json index d073f963..b1e62b03 100755 --- a/front/plugins/_publisher_email/config.json +++ b/front/plugins/_publisher_email/config.json @@ -1,424 +1,683 @@ { - "code_name": "_publisher_apprise", + "code_name": "_publisher_email", "unique_prefix": "SMTP", - "enabled": true, - "data_source": "script", + "enabled": true, + "data_source": "script", "show_ui": true, - "localized": ["display_name", "description", "icon"], - "display_name" : [ - { - "language_code": "en_us", - "string" : "Apprise publisher" - }, - { - "language_code": "es_es", - "string" : "Habilitar Apprise" - } + "localized": [ + "display_name", + "description", + "icon" ], - "icon":[{ + "display_name": [ + { "language_code": "en_us", - "string" : "" - }], + "string": "Email publisher (SMTP)" + }, + { + "language_code": "es_es", + "string": "Habilitar email (SMTP)" + } + ], + "icon": [ + { + "language_code": "en_us", + "string": "" + } + ], "description": [ - { + { "language_code": "en_us", - "string" : "A plugin to publish a notification via the Apprise gateway." - } + "string": "A plugin to publish a notification via Email (SMTP) gateway." + } ], - "params" : [], - "database_column_definitions": - [ - { - "column": "Index", - "css_classes": "col-sm-2", - "show": false, - "type": "label", - "default_value":"", - "options": [], - "localized": ["name"], - "name":[{ - "language_code": "en_us", - "string" : "N/A" - }, - { - "language_code": "es_es", - "string" : "N/A" - }] - }, - { - "column": "Plugin", - "css_classes": "col-sm-2", - "show": false, - "type": "label", - "default_value":"", - "options": [], - "localized": ["name"], - "name":[{ - "language_code": "en_us", - "string" : "N/A" - }, - { - "language_code": "es_es", - "string" : "N/A" - }] - }, - { - "column": "Object_PrimaryID", - "css_classes": "col-sm-2", - "show": false, - "type": "url", - "default_value":"", - "options": [], - "localized": ["name"], - "name":[{ - "language_code": "en_us", - "string" : "N/A" - }] - }, - { - "column": "Object_SecondaryID", - "css_classes": "col-sm-2", - "show": false, - "type": "label", - "default_value":"", - "options": [], - "localized": ["name"], - "name":[{ - "language_code": "en_us", - "string" : "N/A" - }, - { - "language_code": "es_es", - "string" : "N/A" - }] - }, - { - "column": "DateTimeCreated", - "css_classes": "col-sm-2", - "show": true, - "type": "label", - "default_value":"", - "options": [], - "localized": ["name"], - "name":[{ - "language_code": "en_us", - "string" : "Sent when" - }] - }, - { - "column": "DateTimeChanged", - "css_classes": "col-sm-2", - "show": false, - "type": "label", - "default_value":"", - "options": [], - "localized": ["name"], - "name":[{ - "language_code": "en_us", - "string" : "Changed" - }, - { - "language_code": "es_es", - "string" : "Cambiado" - }] - }, - { - "column": "Watched_Value1", - "css_classes": "col-sm-2", - "show": true, - "type": "label", - "default_value":"", - "options": [], - "localized": ["name"], - "name":[{ - "language_code": "en_us", - "string" : "Notification GUID" - }] - }, - { - "column": "Watched_Value2", - "css_classes": "col-sm-8", - "show": true, - "type": "textarea_readonly", - "default_value":"", - "options": [], - "localized": ["name"], - "name":[{ - "language_code": "en_us", - "string" : "Result" - }] - }, - { - "column": "Watched_Value3", - "css_classes": "col-sm-2", - "show": false, - "type": "label", - "default_value":"", - "options": [], - "localized": ["name"], - "name":[{ - "language_code": "en_us", - "string" : "N/A" - }, - { - "language_code": "es_es", - "string" : "N/A" - }] - }, - { - "column": "Watched_Value4", - "css_classes": "col-sm-2", - "show": false, - "type": "label", - "default_value":"", - "options": [], - "localized": ["name"], - "name":[{ - "language_code": "en_us", - "string" : "N/A" - }, - { - "language_code": "es_es", - "string" : "N/A" - }] - }, - { - "column": "UserData", - "css_classes": "col-sm-2", - "show": false, - "type": "textbox_save", - "default_value":"", - "options": [], - "localized": ["name"], - "name":[{ - "language_code": "en_us", - "string" : "Comments" - }, - { - "language_code": "es_es", - "string" : "Comentarios" - }] - }, - { - "column": "Status", - "css_classes": "col-sm-1", - "show": false, - "type": "replace", - "default_value":"", - "options": [ - { - "equals": "watched-not-changed", - "replacement": "
      " - }, - { - "equals": "watched-changed", - "replacement": "
      " - }, - { - "equals": "new", - "replacement": "
      " - }, - { - "equals": "missing-in-last-scan", - "replacement": "
      " - } - ], - "localized": ["name"], - "name":[{ - "language_code": "en_us", - "string" : "Status" - }, - { - "language_code": "es_es", - "string" : "Estado" - }] - }, - { - "column": "Extra", - "css_classes": "col-sm-3", - "show": false, - "type": "label", - "default_value":"", - "options": [], - "localized": ["name"], - "name":[{ - "language_code": "en_us", - "string" : "Extra" - }, - { - "language_code": "es_es", - "string" : "Extra" - }] - } + "params": [], + "database_column_definitions": [ + { + "column": "Index", + "css_classes": "col-sm-2", + "show": false, + "type": "label", + "default_value": "", + "options": [], + "localized": [ + "name" + ], + "name": [ + { + "language_code": "en_us", + "string": "N/A" + }, + { + "language_code": "es_es", + "string": "N/A" + } + ] + }, + { + "column": "Plugin", + "css_classes": "col-sm-2", + "show": false, + "type": "label", + "default_value": "", + "options": [], + "localized": [ + "name" + ], + "name": [ + { + "language_code": "en_us", + "string": "N/A" + }, + { + "language_code": "es_es", + "string": "N/A" + } + ] + }, + { + "column": "Object_PrimaryID", + "css_classes": "col-sm-2", + "show": false, + "type": "url", + "default_value": "", + "options": [], + "localized": [ + "name" + ], + "name": [ + { + "language_code": "en_us", + "string": "N/A" + } + ] + }, + { + "column": "Object_SecondaryID", + "css_classes": "col-sm-2", + "show": false, + "type": "label", + "default_value": "", + "options": [], + "localized": [ + "name" + ], + "name": [ + { + "language_code": "en_us", + "string": "N/A" + }, + { + "language_code": "es_es", + "string": "N/A" + } + ] + }, + { + "column": "DateTimeCreated", + "css_classes": "col-sm-2", + "show": true, + "type": "label", + "default_value": "", + "options": [], + "localized": [ + "name" + ], + "name": [ + { + "language_code": "en_us", + "string": "Sent when" + } + ] + }, + { + "column": "DateTimeChanged", + "css_classes": "col-sm-2", + "show": false, + "type": "label", + "default_value": "", + "options": [], + "localized": [ + "name" + ], + "name": [ + { + "language_code": "en_us", + "string": "Changed" + }, + { + "language_code": "es_es", + "string": "Cambiado" + } + ] + }, + { + "column": "Watched_Value1", + "css_classes": "col-sm-2", + "show": true, + "type": "label", + "default_value": "", + "options": [], + "localized": [ + "name" + ], + "name": [ + { + "language_code": "en_us", + "string": "Notification GUID" + } + ] + }, + { + "column": "Watched_Value2", + "css_classes": "col-sm-8", + "show": true, + "type": "textarea_readonly", + "default_value": "", + "options": [], + "localized": [ + "name" + ], + "name": [ + { + "language_code": "en_us", + "string": "Result" + } + ] + }, + { + "column": "Watched_Value3", + "css_classes": "col-sm-2", + "show": false, + "type": "label", + "default_value": "", + "options": [], + "localized": [ + "name" + ], + "name": [ + { + "language_code": "en_us", + "string": "N/A" + }, + { + "language_code": "es_es", + "string": "N/A" + } + ] + }, + { + "column": "Watched_Value4", + "css_classes": "col-sm-2", + "show": false, + "type": "label", + "default_value": "", + "options": [], + "localized": [ + "name" + ], + "name": [ + { + "language_code": "en_us", + "string": "N/A" + }, + { + "language_code": "es_es", + "string": "N/A" + } + ] + }, + { + "column": "UserData", + "css_classes": "col-sm-2", + "show": false, + "type": "textbox_save", + "default_value": "", + "options": [], + "localized": [ + "name" + ], + "name": [ + { + "language_code": "en_us", + "string": "Comments" + }, + { + "language_code": "es_es", + "string": "Comentarios" + } + ] + }, + { + "column": "Status", + "css_classes": "col-sm-1", + "show": false, + "type": "replace", + "default_value": "", + "options": [ + { + "equals": "watched-not-changed", + "replacement": "
      " + }, + { + "equals": "watched-changed", + "replacement": "
      " + }, + { + "equals": "new", + "replacement": "
      " + }, + { + "equals": "missing-in-last-scan", + "replacement": "
      " + } + ], + "localized": [ + "name" + ], + "name": [ + { + "language_code": "en_us", + "string": "Status" + }, + { + "language_code": "es_es", + "string": "Estado" + } + ] + }, + { + "column": "Extra", + "css_classes": "col-sm-3", + "show": false, + "type": "label", + "default_value": "", + "options": [], + "localized": [ + "name" + ], + "name": [ + { + "language_code": "en_us", + "string": "Extra" + }, + { + "language_code": "es_es", + "string": "Extra" + } + ] + } ], - "settings":[ - { - "function": "RUN", - "events": ["test"], - "type": "text.select", - "default_value":"disabled", - "options": ["disabled", "on_notification" ], - "localized": ["name", "description"], - "name" :[{ - "language_code": "en_us", - "string" : "When to run" - }, - { - "language_code": "es_es", - "string" : "Cuando ejecuta" - }], - "description": [ - { - "language_code": "en_us", - "string" : "Enable sending notifications via Apprise." - }, - { - "language_code": "es_es", - "string" : "Habilitar el envío de notificaciones a través de Apprise." - } - ] - }, - { - "function": "CMD", - "type": "readonly", - "default_value":"python3 /home/pi/pialert/front/plugins/_publisher_email/email.py", - "options": [], - "localized": ["name", "description"], - "name" : [{ - "language_code": "en_us", - "string" : "Command" - }, - { - "language_code": "es_es", - "string" : "Comando" - }], - "description": [{ - "language_code": "en_us", - "string" : "Command to run" - }, - { - "language_code": "es_es", - "string" : "Comando a ejecutar" - }] - }, - { - "function": "RUN_TIMEOUT", - "type": "integer", - "default_value": 10, - "options": [], - "localized": ["name", "description"], - "name" : [{ - "language_code": "en_us", - "string" : "Run timeout" - }, - { - "language_code": "es_es", - "string" : "Tiempo de espera de ejecución" - }, - { - "language_code": "de_de", - "string" : "Wartezeit" - }], - "description": [{ - "language_code": "en_us", - "string" : "Maximum time in seconds to wait for the script to finish. If this time is exceeded the script is aborted." - }, - { - "language_code": "es_es", - "string" : "Tiempo máximo en segundos para esperar a que finalice el script. Si se supera este tiempo, el script se cancela." - }] - }, - { - "function": "HOST", - "type": "text", - "default_value": "", - "options": [], - "localized": ["name", "description"], - "name" : [{ - "language_code": "en_us", - "string" : "Apprise host URL" - }, - { - "language_code": "es_es", - "string" : "URL del host de Apprise" - }], - "description": [{ - "language_code": "en_us", - "string" : "Apprise host URL starting with http:// or https://. (do not forget to include /notify at the end)" - }, - { - "language_code": "es_es", - "string" : "URL del host de Apprise que comienza con http:// o https://. (no olvide incluir /notify al final)" - }] - }, - { - "function": "URL", - "type": "text", - "default_value": "", - "options": [], - "localized": ["name", "description"], - "name" : [{ - "language_code": "en_us", - "string" : "Apprise notification URL" - }, - { - "language_code": "es_es", - "string" : "URL de notificación de Apprise" - }], - "description": [{ - "language_code": "en_us", - "string" : "Apprise notification target URL. For example for Telegram it would be tgram://{bot_token}/{chat_id}." - }, - { - "language_code": "es_es", - "string" : "Informar de la URL de destino de la notificación. Por ejemplo, para Telegram sería tgram://{bot_token}/{chat_id}." - }] - }, - { - "function": "PAYLOAD", - "type": "text.select", - "default_value": "html", - "options": ["html", "text"], - "localized": ["name", "description"], - "name" : [{ - "language_code": "en_us", - "string" : "Payload type" - }, - { - "language_code": "es_es", - "string" : "Tipo de carga" - }], - "description": [{ - "language_code": "en_us", - "string" : "Select the payoad type sent to Apprise. For example html works well with emails, text with chat apps, such as Telegram." - }, - { - "language_code": "es_es", - "string" : "Seleccione el tipo de carga útil enviada a Apprise. Por ejemplo, html funciona bien con correos electrónicos, text con aplicaciones de chat, como Telegram." - }] - }, - { - "function": "SIZE", - "type": "integer", - "default_value": 1024, - "options": [], - "localized": ["name", "description"], - "name" : [{ - "language_code": "en_us", - "string" : "Max payload size" - }, - { - "language_code": "es_es", - "string" : "Tamaño máximo de carga útil" - }], - "description": [{ - "language_code": "en_us", - "string" : "The maximum size of the apprise payload as number of characters in the passed string. If above limit, it will be truncated and a (text was truncated) message is appended." - }, - { - "language_code": "es_es", - "string" : "El tamaño máximo de la carga útil de información como número de caracteres en la cadena pasada. Si supera el límite, se truncará y se agregará un mensaje (text was truncated)." - }] - } + "settings": [ + { + "function": "RUN", + "events": [ + "test" + ], + "type": "text.select", + "default_value": "disabled", + "options": [ + "disabled", + "on_notification" + ], + "localized": [ + "name", + "description" + ], + "name": [ + { + "language_code": "en_us", + "string": "When to run" + }, + { + "language_code": "es_es", + "string": "Cuando ejecuta" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "Enable sending notifications via the Email (SMTP) gateway." + }, + { + "language_code": "es_es", + "string": "Si está habilitado, se envía un correo electrónico con una lista de cambios a los que se ha suscrito. Complete también todas las configuraciones restantes relacionadas con la configuración de SMTP a continuación" + } + ] + }, + { + "function": "CMD", + "type": "readonly", + "default_value": "python3 /home/pi/pialert/front/plugins/_publisher_email/email.py", + "options": [], + "localized": [ + "name", + "description" + ], + "name": [ + { + "language_code": "en_us", + "string": "Command" + }, + { + "language_code": "es_es", + "string": "Comando" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "Command to run" + }, + { + "language_code": "es_es", + "string": "Comando a ejecutar" + } + ] + }, + { + "function": "RUN_TIMEOUT", + "type": "integer", + "default_value": 20, + "options": [], + "localized": [ + "name", + "description" + ], + "name": [ + { + "language_code": "en_us", + "string": "Run timeout" + }, + { + "language_code": "es_es", + "string": "Tiempo de espera de ejecución" + }, + { + "language_code": "de_de", + "string": "Wartezeit" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "Maximum time in seconds to wait for the script to finish. If this time is exceeded the script is aborted." + }, + { + "language_code": "es_es", + "string": "Tiempo máximo en segundos para esperar a que finalice el script. Si se supera este tiempo, el script se cancela." + } + ] + }, + { + "function": "SERVER", + "type": "text", + "default_value": "", + "options": [], + "localized": [ + "name", + "description" + ], + "name": [ + { + "language_code": "en_us", + "string": "SMTP server URL" + }, + { + "language_code": "es_es", + "string": "URL del servidor SMTP" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "The SMTP server host URL. For example smtp-relay.sendinblue.com. To use Gmail as an SMTP server follow this guide" + }, + { + "language_code": "es_es", + "string": "La URL del host del servidor SMTP. Por ejemplo, smtp-relay.sendinblue.com. Para utilizar Gmail como servidor SMTP siga esta guía" + } + ] + }, + { + "function": "PORT", + "type": "integer", + "default_value": 587, + "options": [], + "localized": [ + "name", + "description" + ], + "name": [ + { + "language_code": "en_us", + "string": "SMTP server PORT" + }, + { + "language_code": "es_es", + "string": "Puerto del servidor SMTP" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "Port number used for the SMTP connection. Set to 0 if you do not want to use a port when connecting to the SMTP server." + }, + { + "language_code": "es_es", + "string": "Número de puerto utilizado para la conexión SMTP. Establézcalo en 0 si no desea utilizar un puerto al conectarse al servidor SMTP." + } + ] + }, + { + "function": "SKIP_LOGIN", + "type": "boolean", + "default_value": false, + "options": [], + "localized": [ + "name", + "description" + ], + "name": [ + { + "language_code": "en_us", + "string": "Skip authentication" + }, + { + "language_code": "es_es", + "string": "Omitir autenticación" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "Do not use authentication when connecting to the SMTP server." + }, + { + "language_code": "es_es", + "string": "No utilice la autenticación cuando se conecte al servidor SMTP." + } + ] + }, + { + "function": "USER", + "type": "text", + "default_value": "", + "options": [], + "localized": [ + "name", + "description" + ], + "name": [ + { + "language_code": "en_us", + "string": "SMTP user" + }, + { + "language_code": "es_es", + "string": "Nombre de usuario SMTP" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "The user name used to login into the SMTP server (sometimes a full email address)." + }, + { + "language_code": "es_es", + "string": "El nombre de usuario utilizado para iniciar sesión en el servidor SMTP (a veces, una dirección de correo electrónico completa)." + } + ] + }, + { + "function": "PASS", + "type": "password", + "default_value": "", + "options": [], + "localized": [ + "name", + "description" + ], + "name": [ + { + "language_code": "en_us", + "string": "SMTP password" + }, + { + "language_code": "es_es", + "string": "Contraseña de SMTP" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "The SMTP server password." + }, + { + "language_code": "es_es", + "string": "La contraseña del servidor SMTP." + } + ] + }, + { + "function": "SKIP_TLS", + "type": "boolean", + "default_value": false, + "options": [], + "localized": [ + "name", + "description" + ], + "name": [ + { + "language_code": "en_us", + "string": "Do not use TLS" + }, + { + "language_code": "es_es", + "string": "No usar TLS" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "Disable TLS when connecting to your SMTP server." + }, + { + "language_code": "es_es", + "string": "Deshabilite TLS cuando se conecte a su servidor SMTP." + } + ] + }, + { + "function": "FORCE_SSL", + "type": "boolean", + "default_value": false, + "options": [], + "localized": [ + "name", + "description" + ], + "name": [ + { + "language_code": "en_us", + "string": "Force SSL" + }, + { + "language_code": "es_es", + "string": "Forzar SSL" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "Force SSL when connecting to your SMTP server." + }, + { + "language_code": "es_es", + "string": "Forzar SSL al conectarse a su servidor SMTP" + } + ] + }, + { + "function": "REPORT_TO", + "type": "text", + "default_value": "user@gmail.com", + "options": [], + "localized": [ + "name", + "description" + ], + "name": [ + { + "language_code": "en_us", + "string": "Send email to" + }, + { + "language_code": "es_es", + "string": "Enviar el email a" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "Email address to which the notification will be send to." + }, + { + "language_code": "es_es", + "string": "Dirección de correo electrónico a la que se enviará la notificación." + } + ] + }, + { + "function": "REPORT_FROM", + "type": "text", + "default_value": "Pi.Alert ", + "options": [], + "localized": [ + "name", + "description" + ], + "name": [ + { + "language_code": "en_us", + "string": "Email subject" + }, + { + "language_code": "es_es", + "string": "Asunto del email" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "Notification email subject line. Some SMTP servers need this to be an email." + }, + { + "language_code": "es_es", + "string": "Asunto del correo electrónico de notificación." + } + ] + } ] -} + } \ No newline at end of file diff --git a/front/plugins/_publisher_email/ignore_plugin b/front/plugins/_publisher_email/ignore_plugin deleted file mode 100755 index 77ffa1c1..00000000 --- a/front/plugins/_publisher_email/ignore_plugin +++ /dev/null @@ -1 +0,0 @@ -This plugin will not be loaded \ No newline at end of file diff --git a/pialert/__main__.py b/pialert/__main__.py index 6eedc75e..f801cc31 100755 --- a/pialert/__main__.py +++ b/pialert/__main__.py @@ -24,7 +24,7 @@ import multiprocessing import conf from const import * from logger import mylog -from helper import filePermissions, timeNowTZ, updateState, get_setting_value, noti_obj +from helper import filePermissions, timeNowTZ, updateState, get_setting_value, noti_obj from api import update_api from networkscan import process_scan from initialise import importConfigs diff --git a/pialert/initialise.py b/pialert/initialise.py index 68690d26..83d24be7 100755 --- a/pialert/initialise.py +++ b/pialert/initialise.py @@ -6,6 +6,9 @@ from cron_converter import Cron from pathlib import Path import datetime import json +import shutil +import re + import conf from const import fullConfPath @@ -62,6 +65,9 @@ def importConfigs (db): # Only import file if the file was modifed since last import. # this avoids time zone issues as we just compare the previous timestamp to the current time stamp + # rename settings that have changed names due to code cleanup and migration to plugins + renameSettings(config_file) + fileModifiedTime = os.path.getmtime(config_file) mylog('debug', ['[Import Config] checking config file ']) @@ -114,18 +120,6 @@ def importConfigs (db): # Notification gateways # ---------------------------------------- - # Email - conf.REPORT_MAIL = ccd('REPORT_MAIL', False , c_d, 'Enable email', 'boolean', '', 'Email', ['test']) - conf.SMTP_SERVER = ccd('SMTP_SERVER', '' , c_d,'SMTP server URL', 'text', '', 'Email') - conf.SMTP_PORT = ccd('SMTP_PORT', 587 , c_d, 'SMTP port', 'integer', '', 'Email') - conf.REPORT_TO = ccd('REPORT_TO', 'user@gmail.com' , c_d, 'Email to', 'text', '', 'Email') - conf.REPORT_FROM = ccd('REPORT_FROM', 'Pi.Alert ' , c_d, 'Email Subject', 'text', '', 'Email') - conf.SMTP_SKIP_LOGIN = ccd('SMTP_SKIP_LOGIN', False , c_d, 'SMTP skip login', 'boolean', '', 'Email') - conf.SMTP_USER = ccd('SMTP_USER', '' , c_d, 'SMTP user', 'text', '', 'Email') - conf.SMTP_PASS = ccd('SMTP_PASS', '' , c_d, 'SMTP password', 'password', '', 'Email') - conf.SMTP_SKIP_TLS = ccd('SMTP_SKIP_TLS', False , c_d, 'SMTP skip TLS', 'boolean', '', 'Email') - conf.SMTP_FORCE_SSL = ccd('SMTP_FORCE_SSL', False , c_d, 'Force SSL', 'boolean', '', 'Email') - # Webhooks conf.REPORT_WEBHOOK = ccd('REPORT_WEBHOOK', False , c_d, 'Enable Webhooks', 'boolean', '', 'Webhooks', ['test']) conf.WEBHOOK_URL = ccd('WEBHOOK_URL', '' , c_d, 'Target URL', 'text', '', 'Webhooks') @@ -283,4 +277,56 @@ def read_config_file(filename): code = compile(filename.read_text(), filename.name, "exec") confDict = {} # config dictionary exec(code, {"__builtins__": {}}, confDict) - return confDict \ No newline at end of file + return confDict + + +#------------------------------------------------------------------------------- +replacements = { + r'\bREPORT_TO\b': 'SMTP_REPORT_TO', + r'\bREPORT_FROM\b': 'SMTP_REPORT_FROM' +} + +def renameSettings(config_file): + # Check if the file contains any of the old setting code names + contains_old_settings = False + + # Open the original config_file for reading + with open(str(config_file), 'r') as original_file: # Convert config_file to a string + for line in original_file: + # Use regular expressions with word boundaries to check for the old setting code names + if any(re.search(key, line) for key in replacements.keys()): + contains_old_settings = True + break # Exit the loop if any old setting is found + + # If the file contains old settings, proceed with renaming and backup + if contains_old_settings: + # Create a backup file with the suffix "_old_setting_names" and timestamp + timestamp = datetime.datetime.now().strftime("%Y%m%d%H%M%S") + backup_file = f"{config_file}_old_setting_names_{timestamp}.bak" + + mylog('debug', f'[Config] Old setting names will be replaced and a backup ({backup_file}) of the config created.') + + shutil.copy(str(config_file), backup_file) # Convert config_file to a string + + # Open the original config_file for reading and create a temporary file for writing + with open(str(config_file), 'r') as original_file, open(str(config_file) + "_temp", 'w') as temp_file: # Convert config_file to a string + for line in original_file: + # Use regular expressions with word boundaries for replacements + for key, value in replacements.items(): + line = re.sub(key, value, line) + + # Write the modified line to the temporary file + temp_file.write(line) + + # Close both files + original_file.close() + temp_file.close() + + # Replace the original config_file with the temporary file + shutil.move(str(config_file) + "_temp", str(config_file)) # Convert config_file to a string + else: + mylog('debug', '[Config] No old setting names found in the file. No changes made.') + + + + \ No newline at end of file diff --git a/pialert/publishers/email.py b/pialert/publishers/email.py deleted file mode 100755 index acf37834..00000000 --- a/pialert/publishers/email.py +++ /dev/null @@ -1,100 +0,0 @@ -""" Pi.Alert module to send notification emails """ - -from email.mime.multipart import MIMEMultipart -from email.mime.text import MIMEText -import smtplib - - -import conf -import socket -from helper import hide_email, noti_obj -from logger import mylog, print_log - -#------------------------------------------------------------------------------- -def check_config (): - if conf.SMTP_SERVER == '' or conf.REPORT_FROM == '' or conf.REPORT_TO == '': - mylog('none', ['[Email Check Config] Error: Email service not set up correctly. Check your pialert.conf SMTP_*, REPORT_FROM and REPORT_TO variables.']) - return False - else: - return True - -#------------------------------------------------------------------------------- -def send (msg: noti_obj): - - pText = msg.text - pHTML = msg.html - - mylog('debug', '[Send Email] REPORT_TO: ' + hide_email(str(conf.REPORT_TO)) + ' SMTP_USER: ' + hide_email(str(conf.SMTP_USER))) - - # Compose email - msg = MIMEMultipart('alternative') - msg['Subject'] = 'Pi.Alert Report' - msg['From'] = conf.REPORT_FROM - msg['To'] = conf.REPORT_TO - msg.attach (MIMEText (pText, 'plain')) - msg.attach (MIMEText (pHTML, 'html')) - - failedAt = '' - - failedAt = print_log ('SMTP try') - - try: - # Send mail - failedAt = print_log('Trying to open connection to ' + str(conf.SMTP_SERVER) + ':' + str(conf.SMTP_PORT)) - - # Set a timeout for the SMTP connection (in seconds) - smtp_timeout = 30 - - if conf.SMTP_FORCE_SSL: - failedAt = print_log('SMTP_FORCE_SSL == True so using .SMTP_SSL()') - if conf.SMTP_PORT == 0: - failedAt = print_log('SMTP_PORT == 0 so sending .SMTP_SSL(SMTP_SERVER)') - smtp_connection = smtplib.SMTP_SSL(conf.SMTP_SERVER) - else: - failedAt = print_log('SMTP_PORT == 0 so sending .SMTP_SSL(SMTP_SERVER, SMTP_PORT)') - smtp_connection = smtplib.SMTP_SSL(conf.SMTP_SERVER, conf.SMTP_PORT, timeout=smtp_timeout) - - else: - failedAt = print_log('SMTP_FORCE_SSL == False so using .SMTP()') - if conf.SMTP_PORT == 0: - failedAt = print_log('SMTP_PORT == 0 so sending .SMTP(SMTP_SERVER)') - smtp_connection = smtplib.SMTP (conf.SMTP_SERVER) - else: - failedAt = print_log('SMTP_PORT == 0 so sending .SMTP(SMTP_SERVER, SMTP_PORT)') - smtp_connection = smtplib.SMTP (conf.SMTP_SERVER, conf.SMTP_PORT) - - failedAt = print_log('Setting SMTP debug level') - - # Log level set to debug of the communication between SMTP server and client - if conf.LOG_LEVEL == 'debug': - smtp_connection.set_debuglevel(1) - - failedAt = print_log( 'Sending .ehlo()') - smtp_connection.ehlo() - - if not conf.SMTP_SKIP_TLS: - failedAt = print_log('SMTP_SKIP_TLS == False so sending .starttls()') - smtp_connection.starttls() - failedAt = print_log('SMTP_SKIP_TLS == False so sending .ehlo()') - smtp_connection.ehlo() - if not conf.SMTP_SKIP_LOGIN: - failedAt = print_log('SMTP_SKIP_LOGIN == False so sending .login()') - smtp_connection.login (conf.SMTP_USER, conf.SMTP_PASS) - - failedAt = print_log('Sending .sendmail()') - smtp_connection.sendmail (conf.REPORT_FROM, conf.REPORT_TO, msg.as_string()) - smtp_connection.quit() - except smtplib.SMTPAuthenticationError as e: - mylog('none', [' ERROR: Failed at - ', failedAt]) - mylog('none', [' ERROR: Couldn\'t connect to the SMTP server (SMTPAuthenticationError), skipping Email (enable LOG_LEVEL=debug for more logging)']) - mylog('none', [' ERROR: ', str(e)]) - except smtplib.SMTPServerDisconnected as e: - mylog('none', [' ERROR: Failed at - ', failedAt]) - mylog('none', [' ERROR: Couldn\'t connect to the SMTP server (SMTPServerDisconnected), skipping Email (enable LOG_LEVEL=debug for more logging)']) - mylog('none', [' ERROR: ', str(e)]) - except socket.gaierror as e: - mylog('none', [' ERROR: Failed at - ', failedAt]) - mylog('none', [' ERROR: Could not resolve hostname (socket.gaierror), skipping Email (enable LOG_LEVEL=debug for more logging)']) - mylog('none', [' ERROR: ', str(e)]) - - mylog('debug', '[Send Email] Last executed - ' + str(failedAt)) \ No newline at end of file diff --git a/pialert/reporting.py b/pialert/reporting.py index 8912d579..4dc99941 100755 --- a/pialert/reporting.py +++ b/pialert/reporting.py @@ -24,8 +24,6 @@ from const import pialertPath, logPath, apiPath from helper import noti_obj, generate_mac_links, removeDuplicateNewLines, timeNowTZ, hide_email, updateState, get_file_content, write_file from logger import logResult, mylog, print_log -from publishers.email import (check_config as email_check_config, - send as send_email ) from publishers.ntfy import (check_config as ntfy_check_config, send as send_ntfy ) from publishers.webhook import (check_config as webhook_check_config, From bd9f68bb27d8b5054a1b6ba9fd23370b8875ef31 Mon Sep 17 00:00:00 2001 From: Jokob-sk Date: Sun, 8 Oct 2023 22:19:54 +1100 Subject: [PATCH 12/13] =?UTF-8?q?Notification=20rework=20-=20SMTP=20v0.3?= =?UTF-8?q?=20-=20WIP=F0=9F=91=B7=E2=80=8D=E2=99=82=EF=B8=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- front/plugins/_publisher_email/config.json | 2 +- .../_publisher_email/{email.py => email_smtp.py} | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) rename front/plugins/_publisher_email/{email.py => email_smtp.py} (89%) diff --git a/front/plugins/_publisher_email/config.json b/front/plugins/_publisher_email/config.json index b1e62b03..bc867b7e 100755 --- a/front/plugins/_publisher_email/config.json +++ b/front/plugins/_publisher_email/config.json @@ -348,7 +348,7 @@ { "function": "CMD", "type": "readonly", - "default_value": "python3 /home/pi/pialert/front/plugins/_publisher_email/email.py", + "default_value": "python3 /home/pi/pialert/front/plugins/_publisher_email/email_smtp.py", "options": [], "localized": [ "name", diff --git a/front/plugins/_publisher_email/email.py b/front/plugins/_publisher_email/email_smtp.py similarity index 89% rename from front/plugins/_publisher_email/email.py rename to front/plugins/_publisher_email/email_smtp.py index 25f1d638..dfb2d379 100755 --- a/front/plugins/_publisher_email/email.py +++ b/front/plugins/_publisher_email/email_smtp.py @@ -72,8 +72,8 @@ def main(): #------------------------------------------------------------------------------- def check_config (): - if get_setting_value('SMTP_SERVER') == '' or get_setting_value('REPORT_FROM') == '' or get_setting_value('REPORT_TO') == '': - mylog('none', ['[Email Check Config] Error: Email service not set up correctly. Check your pialert.conf SMTP_*, REPORT_FROM and REPORT_TO variables.']) + if get_setting_value('SMTP_SERVER') == '' or get_setting_value('SMTP_REPORT_FROM') == '' or get_setting_value('SMTP_REPORT_TO') == '': + mylog('none', ['[Email Check Config] Error: Email service not set up correctly. Check your pialert.conf SMTP_*, SMTP_REPORT_FROM and SMTP_REPORT_TO variables.']) return False else: return True @@ -81,13 +81,13 @@ def check_config (): #------------------------------------------------------------------------------- def send(pHTML, pText): - mylog('debug', [f'[{pluginName}] REPORT_TO: {hide_email(str(get_setting_value('REPORT_TO')))} SMTP_USER: {hide_email(str(get_setting_value('SMTP_USER')))}']) + mylog('debug', [f'[{pluginName}] SMTP_REPORT_TO: {hide_email(str(get_setting_value("SMTP_REPORT_TO")))} SMTP_USER: {hide_email(str(get_setting_value("SMTP_USER")))}']) # Compose email msg = MIMEMultipart('alternative') msg['Subject'] = 'Pi.Alert Report' - msg['From'] = get_setting_value('REPORT_FROM') - msg['To'] = get_setting_value('REPORT_TO') + msg['From'] = get_setting_value('SMTP_REPORT_FROM') + msg['To'] = get_setting_value('SMTP_REPORT_TO') msg.attach (MIMEText (pText, 'plain')) msg.attach (MIMEText (pHTML, 'html')) @@ -135,11 +135,11 @@ def send(pHTML, pText): failedAt = print_log('SMTP_SKIP_TLS == False so sending .ehlo()') smtp_connection.ehlo() if not get_setting_value('SMTP_SKIP_LOGIN'): - failedAt = print_log('SMTP_SKIP_LOGIN') == False so sending .login()') + failedAt = print_log('SMTP_SKIP_LOGIN == False so sending .login()') smtp_connection.login (get_setting_value('SMTP_USER'), get_setting_value('SMTP_PASS')) failedAt = print_log('Sending .sendmail()') - smtp_connection.sendmail (get_setting_value('REPORT_FROM'), get_setting_value('REPORT_TO'), msg.as_string()) + smtp_connection.sendmail (get_setting_value('SMTP_REPORT_FROM'), get_setting_value('SMTP_REPORT_TO'), msg.as_string()) smtp_connection.quit() except smtplib.SMTPAuthenticationError as e: mylog('none', [' ERROR: Failed at - ', failedAt]) From 78c18aa100d183627105c91a19eb108fd41778d2 Mon Sep 17 00:00:00 2001 From: Jokob-sk Date: Sun, 8 Oct 2023 22:49:50 +1100 Subject: [PATCH 13/13] =?UTF-8?q?Notification=20rework=20-=20SMTP=20v0.3?= =?UTF-8?q?=20-=20WIP=F0=9F=91=B7=E2=80=8D=E2=99=82=EF=B8=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pialert/device.py | 11 ++--------- pialert/reporting.py | 6 ++++-- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/pialert/device.py b/pialert/device.py index 580e52fa..5ed3d7ab 100755 --- a/pialert/device.py +++ b/pialert/device.py @@ -14,17 +14,10 @@ def save_scanned_devices (db): sql = db.sql #TO-DO - # #76 Add Local MAC of default local interface - # BUGFIX #106 - Device that pialert is running - # local_mac_cmd = ["bash -lc ifconfig `ip route list default | awk {'print $5'}` | grep ether | awk '{print $2}'"] - # local_mac_cmd = ["/sbin/ifconfig `ip route list default | sort -nk11 | head -1 | awk {'print $5'}` | grep ether | awk '{print $2}'"] + # Add Local MAC of default local interface local_mac_cmd = ["/sbin/ifconfig `ip -o route get 1 | sed 's/^.*dev \\([^ ]*\\).*$/\\1/;q'` | grep ether | awk '{print $2}'"] local_mac = subprocess.Popen (local_mac_cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT).communicate()[0].decode().strip() - # local_dev_cmd = ["ip -o route get 1 | sed 's/^.*dev \\([^ ]*\\).*$/\\1/;q'"] - # local_dev = subprocess.Popen (local_dev_cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT).communicate()[0].decode().strip() - - # local_ip_cmd = ["ip route list default | awk {'print $7'}"] local_ip_cmd = ["ip -o route get 1 | sed 's/^.*src \\([^ ]*\\).*$/\\1/;q'"] local_ip = subprocess.Popen (local_ip_cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT).communicate()[0].decode().strip() @@ -149,7 +142,7 @@ def create_new_devices (db): FROM CurrentScan""" - # mylog('debug',f'[New Devices] 2 Create devices SQL: {sqlQuery}') + mylog('debug',f'[New Devices] 2 Create devices SQL: {sqlQuery}') sql.execute (sqlQuery, (startTime, startTime) ) diff --git a/pialert/reporting.py b/pialert/reporting.py index 4dc99941..510af85e 100755 --- a/pialert/reporting.py +++ b/pialert/reporting.py @@ -259,10 +259,12 @@ def get_notifications (db): write_file (logPath + '/report_output.txt', final_text) write_file (logPath + '/report_output.html', final_html) + mylog('minimal', ['[Notification] Udating API files']) + send_api() + return noti_obj(final_json, final_text, final_html) - # mylog('minimal', ['[Notification] Udating API files']) - # send_api() + # if conf.REPORT_MAIL and check_config('email'): # updateState("Send: Email")