diff --git a/README.md b/README.md index 90090bd..4685b74 100644 --- a/README.md +++ b/README.md @@ -2,30 +2,33 @@ Python script that checks for Apple software updates, and notifies via Telegram Bot. -## Previous steps - This script relies on a previously created Telegram bot. If you don't have one follow [this steps](https://www.alphr.com/telegram-create-bot/) to create one. You need your bot token at hand in order to run this script. -Additionally, you have to install all dependencies listed in *requirements.txt* using +## Previous steps +Clone this repo with... + ``` -pip install -r requirements.txt +git clone https://github.com/Geek-MD/apple-security-updates-notifier.git ``` -## Basic installation and configuration +Now execute the *start.sh* file with the following command. This will install all dependencies needed by the script +listed on *requirements.txt*. -Clone this repo with... - ``` -git clone https://github.com/Geek-MD/apple-security-updates-notifier.git +./start.sh ``` +Once the script is finished, you will get help information on how to run the python script. + +## Basic configuration + To run the script you must add *-b* or *--bot-token* option followed by the bot token itself. Bot token is mandatory, if you don't provide it, the script will exit with an error. Timezone or country arguments are optional. The default timezone is UTC. The basic syntax to run the script is as the following examples. ``` -python3 config.py -b -python3 config.py --bot-token +python3 asu-notifier.py -b +python3 asu-notifier.py --bot-token ``` You can check the script help by using *-h* or *--help* option. @@ -44,7 +47,8 @@ python3 config.py --version ## Advanced configuration -There are two options, independent one from the other, to define the time zone of the bot. You can use timezone or country options. Remember that this is optional. +There are two options, independent one from the other, to define the bot timezone. You can use timezone or country +options. Remember that this is optional. Additionally, you can define the chat ids where Telegram notifications will be sent. ### Timezone or country configuration @@ -77,26 +81,6 @@ python3 config.py -b -i python3 config.py --bot-token --chat-ids ``` -## Functionality - -This piece of software comprehends 2 *.py* files, 1 *.json* file and 1 *.sh* file. Python files are *config.py* which runs just once at start, and *apple_security_updates.py* which is the persistent script. The JSON file is *asu-notifier.json* which contains some basic information for *config.py*. Finally, the bash file is *asu-notifier.sh* which contains shell commands in order to run the persistent Python script as a systemd service. - -Once the script is run, it will automatically recreate 2 files using the information you gave it, a *asu-notifier.service* file used to start a *systemd* service, and a *config.json* file with the configuration needed by the persistent script. - -Finally, the script will execute the bash file in order to enable and start the systemd service. This will take some time to start because of *ExecStartPre* section which will wait for 60 seconds. This is due to a recommendation found on several forums which intention is to ensure that the script executes only when the system is fully loaded. - -When you see the system prompt, if you haven't received an error message, the script will be running in background, and you'll receive a Telegram Notification with the latest Apple Security Updates. - -## Troubleshooting - -Eventually, the systemd service may need to be restarted due to a system restart or by any other reason. You can do it manually using the following shell commands. - -``` -sudo systemctl daemon-reload -sudo systemctl enable asu-notifier.service -sudo systemctl start asu-notifier.service -``` - ## Note This is a first workaround attempt for a permanent bot in the near future, instead of a self-hosted bot. \ No newline at end of file diff --git a/asu-bot.py b/asu-bot.py index 54f50c9..d3563fd 100644 --- a/asu-bot.py +++ b/asu-bot.py @@ -1,54 +1,49 @@ #!/usr/bin/env python3 -# Apple Security Updates Notifier v0.4.1 -# Python script that checks for Apple software updates, and notifies via Telegram Bot. -# This is a first workaround attempt for a permanent bot in the near future. +# Apple Security Updates Notifier v0.4.2 +# File: asu-bot.py +# Description: Secondary component of ASU Notifier, which will run hourly and notify via Telegram of any new security +# update. -import contextlib import datetime import hashlib import json import logging import os +import os.path import re import sqlite3 -import time from datetime import datetime from sqlite3 import Error, Connection from typing import TypeVar import pytz import requests -import schedule from apprise import Apprise from bs4 import BeautifulSoup -working_dir = os.getcwd() -os.chdir(working_dir) - # set global variables global apple_url, db_file, log_file, localtime, bot_token, chat_ids # SQL queries -sql_check_empty_database: str = """ SELECT COUNT(name) FROM sqlite_master WHERE type='table' AND name='main' """ -sql_create_main_table: str = """ CREATE TABLE IF NOT EXISTS main ( main_id integer PRIMARY KEY AUTOINCREMENT, log_date text NOT -NULL, file_hash text NOT NULL, log_message text NOT NULL ); """ -sql_create_updates_table: str = """ CREATE TABLE IF NOT EXISTS updates ( update_id integer PRIMARY KEY AUTOINCREMENT, update_date -text NOT NULL, update_product text NOT NULL, update_target text NOT NULL, update_link text, file_hash text NOT NULL ); """ sql_main_table_hash_check: str = """ SELECT COUNT(*) FROM main WHERE file_hash = ? """ sql_main_table: str = """ INSERT INTO main (log_date, file_hash, log_message) VALUES (?, ?, ?); """ sql_updates_table: str = """ INSERT INTO updates (update_date, update_product, update_target, update_link, file_hash) VALUES (?, ?, ?, ?, ?); """ -sql_get_updates: str = """ SELECT update_date, update_product, update_target, update_link FROM updates ORDER BY update_id ASC; """ +sql_get_updates: str = """SELECT update_date, update_product, update_target, update_link FROM updates ORDER BY +update_id ASC;""" sql_get_updates_count: str = """ SELECT count(update_date) FROM updates WHERE update_date = ?; """ -sql_get_last_updates: str = """ SELECT update_date, update_product, update_target, update_link FROM updates WHERE update_date = ?; """ +sql_get_last_updates: str = """SELECT update_date, update_product, update_target, update_link FROM updates WHERE +update_date = ?;""" sql_get_last_update_date: str = """ SELECT update_date FROM updates ORDER BY update_id DESC LIMIT 1; """ sql_get_update_dates: str = """ SELECT DISTINCT update_date FROM updates; """ -sql_get_date_update: str = """ SELECT update_date, update_product, update_target, update_link FROM updates WHERE update_date = ?; """ +sql_get_date_update: str = """SELECT update_date, update_product, update_target, update_link FROM updates WHERE +update_date = ?;""" + -def get_config(): +def get_config(local_path): global apple_url, db_file, log_file, localtime, bot_token, chat_ids - config = open('config.json', 'r') + config = open(f'{local_path}/config.json', 'r') data = json.loads(config.read()) apple_url = data['apple_url'] db_file = data['db_file'] @@ -68,14 +63,6 @@ def create_connection(file): logging.error(str(error)) return conn -def create_table(conn, sql_create_table, table_name, file): - with contextlib.suppress(Error): - try: - conn.cursor().execute(sql_create_table) - logging.info(f'\'{file}\' - \'{table_name}\' table created.') - except Error as error: - logging.error(str(error)) - def get_updates(conn, full_update): cursor = conn.cursor() response = requests.get(apple_url) @@ -121,6 +108,7 @@ def update_updates_database(conn, file_hash, content, full_update): cursor.execute(sql_updates_table, (element[0], element[1], element[2], element[3], file_hash)) logging.info(log_message) conn.commit() + recent_updates.reverse() apprise_notification(conn, recent_updates, full_update) def formatted_content(content): @@ -162,7 +150,7 @@ def apprise_notification(conn, updates, full_update): apprise_syntax = f'tgram://{bot_token}/' for chat_id in chat_ids: apprise_syntax += f'{chat_id}/' - apprise_syntax *= '?format=markdown' + apprise_syntax += '?format=markdown' apprise_object.add(apprise_syntax, tag='telegram') apprise_object.notify(apprise_message, tag="telegram") @@ -173,7 +161,7 @@ def build_message(conn, last_updates, full_update): last_updates = [] update_dates = cursor.execute(sql_get_update_dates).fetchall() for element in reversed(update_dates): - if max_updates >= 0: + if max_updates > 0: query = cursor.execute(sql_get_date_update, element).fetchall() query_count = cursor.execute(sql_get_updates_count, element).fetchone()[0] last_updates += query @@ -196,7 +184,9 @@ def build_message(conn, last_updates, full_update): return apprise_message def main(): - get_config() + local_file = __file__ + local_path = os.path.dirname(local_file) + get_config(local_path) # logging log_format = '%(asctime)s -- %(message)s' @@ -204,23 +194,9 @@ def main(): # create a database connection conn: Connection = create_connection(db_file) - cursor = conn.cursor() - empty_database = cursor.execute(sql_check_empty_database).fetchone()[0] == 0 - if empty_database: - # create database tables and populate them - create_table(conn, sql_create_main_table, 'main', db_file) - create_table(conn, sql_create_updates_table, 'updates', db_file) # run first database update get_updates(conn, full_update=True) - # Schedule the function to run every hour - schedule.every().hour.do(get_updates, conn=conn, full_update=False) - - # Run the scheduler continuously - while True: - schedule.run_pending() - time.sleep(1) - if __name__ == '__main__': - main() \ No newline at end of file + main() diff --git a/asu-notifier.json b/asu-notifier.json index 7cedcdb..f595588 100644 --- a/asu-notifier.json +++ b/asu-notifier.json @@ -1,6 +1,6 @@ { "prog_name_short": "asu-notifier", "prog_name_long": "Apple Security Updates Notifier", - "version": "v0.4.1", + "version": "v0.4.2", "apple_url": "https://support.apple.com/es-es/HT201222" } \ No newline at end of file diff --git a/asu-notifier.py b/asu-notifier.py index 596c8b0..3ddad39 100644 --- a/asu-notifier.py +++ b/asu-notifier.py @@ -1,22 +1,58 @@ #!/usr/bin/env python3 -# This script recreates config.json and asu-notifier.service files. +# Apple Security Updates Notifier v0.4.2 +# File: asu-notifier.py +# Description: Main component of ASU Notifier, used to create config.json file, initialize the database, and set a +# cronjob which will run the secondary component of ASU Notifier hourly. import argparse +import contextlib import json +import logging import os import re -import subprocess +import sqlite3 import textwrap import urllib.request +from sqlite3 import Error, Connection +from typing import TypeVar import pycountry import pytz import requests +from crontab import CronTab +# SQL queries +sql_check_empty_database: str = """ SELECT COUNT(name) FROM sqlite_master WHERE type='table' AND name='main' """ +sql_create_main_table: str = """CREATE TABLE IF NOT EXISTS main ( main_id integer PRIMARY KEY AUTOINCREMENT, log_date +text NOT NULL, file_hash text NOT NULL, log_message text NOT NULL );""" +sql_create_updates_table: str = """CREATE TABLE IF NOT EXISTS updates ( update_id integer PRIMARY KEY AUTOINCREMENT, +update_date text NOT NULL, update_product text NOT NULL, update_target text NOT NULL, update_link text, +file_hash text NOT NULL );""" -def get_config(): - config = open('asu-notifier.json', 'r') + +def create_connection(file): + if not os.path.isfile(file): + logging.info(f'\'{file}\' database created.') + conn = TypeVar('conn', Connection, None) + try: + conn: Connection = sqlite3.connect(file) + except Error as error: + logging.error(str(error)) + return conn + + +def create_table(conn, sql_create_table, table_name, file): + with contextlib.suppress(Error): + try: + conn.cursor().execute(sql_create_table) + logging.info(f'\'{file}\' - \'{table_name}\' table created.') + except Error as error: + logging.error(str(error)) + + +def get_config(local_path): + config = open(f'{local_path}/asu-notifier.json', 'r') data = json.loads(config.read()) prog_name_short = data['prog_name_short'] prog_name_long = data['prog_name_long'] @@ -24,44 +60,36 @@ def get_config(): apple_url = data['apple_url'] return prog_name_short, prog_name_long, version, apple_url -def create_service_file(working_dir, python_path, prog_name): - service_str = f"""[Unit] -Description={prog_name} -After=multi-user.target - -[Service] -Type=simple -Environment=DISPLAY=:0 -ExecStartPre=/bin/sleep 60 -ExecStart=/usr/bin/python3 {working_dir}/asu-bot.py -Restart=on-failure -RestartSec=30s -KillMode=process -TimeoutSec=infinity -Environment="PYTHONPATH=$PYTHONPATH:{python_path}/" - -[Install] -WantedBy=multi-user.target""" - with open("asu-notifier.service", "w") as file: - file.write(service_str) - -def create_config_json(apple_url, prog_name, bot_token, chat_ids, tzone): + +def create_config_json(local_path, apple_url, prog_name, bot_token, chat_ids, tzone): config_str = f""" "apple_url": "{apple_url}", - "db_file": "{prog_name}.db", - "log_file": "{prog_name}.log", + "db_file": "{local_path}/{prog_name}.db", + "log_file": "{local_path}/{prog_name}.log", "timezone": "{tzone}", "bot_token": "{bot_token}", "chat_ids": [ """ for i, value in enumerate(chat_ids): - if i+1 < len(chat_ids): + if i + 1 < len(chat_ids): config_str += f' "{value}", \n' else: config_str += f' "{value}"\n' config_str += " ]" config_str = "{\n" + config_str + "\n}" - with open("config.json", "w") as file: + with open(f"{local_path}/config.json", "w") as file: file.write(config_str) + return f'{local_path}/{prog_name}.log', f'{local_path}/{prog_name}.db' + + +def crontab_job(working_dir): + cronjob = CronTab(user=True) + comment = "asu-notifier" + comment_found = any(job.comment == comment for job in cronjob) + if not comment_found: + job = cronjob.new(command=f'python3 {working_dir}/asu-bot.py', comment='asu-notifier') + job.setall('0 */6 * * *') + job.enable() + cronjob.write() def token_validator(bot_token): token_json = requests.get(f"https://api.telegram.org/bot{bot_token}/getMe") @@ -72,21 +100,21 @@ def timezone_selection(country_code): country_name = pycountry.countries.get(alpha_2=country_code).name country_timezones = pytz.country_timezones[country_code] country_tz_len = len(country_timezones) - selection_bool = False + selection = False print(f'{country_name} [{country_code}] timezones:') print('0: Switch back to default timezone (UTC)') if country_tz_len == 1: return country_timezones[0] for i, tz in enumerate(country_timezones): print(f'{i + 1}: {tz}') - while not selection_bool: + while not selection: tz_selection = input("Select a timezone: ") index = int(tz_selection) - 1 if int(tz_selection) == 0: - selection_bool = True + selection = True return 'UTC' elif country_tz_len >= int(tz_selection) > 0: - selection_bool = True + selection = True return country_timezones[index] else: print(f'Wrong choice, pick a number between 0 and {country_tz_len}') @@ -97,19 +125,22 @@ def undefined_timezone(): country_name = location_data.get("country_name") country_code = location_data.get("country_code") if country_code is None or country_name is None: - print(f"I can´t identify your country based on your IP [{external_ip}], so you have to set timezone or country manually.") + print(f"I can´t identify your country based on your IP [{external_ip}], so you have to set timezone or " + f"country manually.") exit(1) else: - print(f'According to your IP address [{external_ip}], it seems that your country is {country_name} [{country_code}]') - selection_bool = False - while not selection_bool: - selection = input("Is this correct? (y/n): ") - if selection == 'n': - print("I can´t identify your country based on your IP, so you have to set timezone or country manually.") - selection_bool = True + print(f"""According to your IP address [{external_ip}], it seems that your country is {country_name} +[{country_code}]""") + selection = False + while not selection: + answer = input("Is this correct? (y/n): ") + if answer == 'n': + print("""I can´t identify your country based on your IP, so you have to set timezone or country +manually.""") + selection = True exit(1) - elif selection == 'y': - selection_bool = True + elif answer == 'y': + selection = True return timezone_selection(country_code) def set_timezone(country_code): @@ -127,15 +158,15 @@ def check_timezone(timezone): if timezone.upper() == "X": return undefined_timezone() elif timezone == 'UTC': - print (f'\nTimezone is set to it\'s default value [UTC].') - selection_bool = False - while not selection_bool: - selection = input("Are you OK with it? (y/n): ") - if selection == 'n': - selection_bool = True + print(f'\nTimezone is set to it\'s default value [UTC].') + selection = False + while not selection: + answer = input("Are you OK with it? (y/n): ") + if answer == 'n': + selection = True return undefined_timezone() - elif selection == 'y': - selection_bool = True + elif answer == 'y': + selection = True return 'UTC' elif timezone not in timezones_list: print("Incorrect timezone.") @@ -164,34 +195,45 @@ def get_chat_ids(): chat_ids = [] print("\nType in chat ids where the script will notify Apple Updates. To finish input, type \"0\".") print("Remember to include the minus sign before each chat id like \"-6746386747\".") - selection_bool = False + selection = False counter = 1 - while not selection_bool: - selection = input(f'Type in chat id #{counter}: ') - if selection == '0': - selection_bool = True + while not selection: + answer = input(f'Type in chat id #{counter}: ') + if answer == '0': + selection = True return chat_ids - elif re.search(regex, selection): - chat_ids.append(selection) + elif re.search(regex, answer): + chat_ids.append(answer) counter += 1 else: print("Incorrect format, try again.") def argument_parser(progname_short, progname_long, ver): - description = f'script used to setup {progname_long}. It creates the systemd service file, config file and starts the service' - epilog = """bot token is made-up of a numerical string of 8-10 digits followed by a ":", and finishes with a 35 alphanumeric string. -for ISO Alpha-2 codes refer to http://iban.com/country-codes. -chat ids must be provided one after another separated by a space, like \"-123456 -4567890\".""" + description = f'{progname_long} is python program that will notify you through Telegram, about new Apple updates.' + epilog = """Bot token is made-up of a numerical string of 8-10 digits followed by a ":", and finishes with a 35 +alphanumeric string. For ISO Alpha-2 codes refer to http://iban.com/country-codes. Chat ids must be provided one +after another separated by a space, like \"-123456 -4567890\".""" parser = argparse.ArgumentParser(prog=f"{progname_short}", description=textwrap.dedent(f"{description}"), epilog=f"{epilog}", formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument('-b', '--bot-token', help='set Telegram bot token', required=True) - parser.add_argument('-t', '--timezone', default='UTC', help='[optional] set bot timezone. Use X or x as argument to allow identification your timezone according to your IP address') - parser.add_argument('-c', '--country', help='[optional] define a country in order to select an appropriate timezone. Use X or x as argument to allow identification your country according to your IP address') + parser.add_argument('-t', '--timezone', default='UTC', help='[optional] Set bot timezone. Use X or x as argument ' + 'to allow identification your timezone according to ' + 'your IP address') + parser.add_argument('-c', '--country', help='[optional] Define a country in order to select an appropriate ' + 'timezone. Use X or x as argument to allow identification your ' + 'country according to your IP address') parser.add_argument('-v', '--version', action='version', version=f'%(prog)s {ver}', - help='show %(prog)s version information and exit') - parser.add_argument('-i', '--chat-ids', default=None, action="extend", nargs="*", type=str, help='[optional] define allowed chat ids at startup. If not set at startup, it will be prompted later in the script') + help='Show %(prog)s version information and exit') + parser.add_argument('-i', '--chat-ids', default=None, action="extend", nargs="*", type=str, help='[optional] ' + 'Define allowed ' + 'chat ids at ' + 'startup. If not ' + 'set at startup, ' + 'it will be ' + 'prompted later ' + 'in the script') args = parser.parse_args() config = vars(args) bot_token = config['bot_token'] @@ -204,14 +246,27 @@ def argument_parser(progname_short, progname_long, ver): return bot_token, timezone, chat_ids def main(): - python_path = subprocess.check_output("which python", shell=True).strip().decode('utf-8') - working_dir = os.getcwd() + local_file = __file__ + local_path = os.path.dirname(local_file) - prog_name_short, prog_name_long, version, apple_url = get_config() + prog_name_short, prog_name_long, version, apple_url = get_config(local_path) bot_token, timezone, chat_ids = argument_parser(prog_name_short, prog_name_long, version) - create_service_file(working_dir, python_path, prog_name_long) - create_config_json(apple_url, prog_name_short, bot_token, chat_ids, timezone) - subprocess.run(f'{working_dir}/asu-notifier.sh') + log_file, db_file = create_config_json(local_path, apple_url, prog_name_short, bot_token, chat_ids, timezone) + + # logging + log_format = '%(asctime)s -- %(message)s' + logging.basicConfig(filename=log_file, encoding='utf-8', format=log_format, level=logging.INFO) + + # create a database connection + conn: Connection = create_connection(db_file) + cursor = conn.cursor() + empty_database = cursor.execute(sql_check_empty_database).fetchone()[0] == 0 + if empty_database: + # create database tables and populate them + create_table(conn, sql_create_main_table, 'main', db_file) + create_table(conn, sql_create_updates_table, 'updates', db_file) + + crontab_job(local_path) if __name__ == '__main__': main() diff --git a/requirements.txt b/requirements.txt index c2c3827..1f35ee8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ -pytz~=2023.3 +pytz==2023.3.post1 requests~=2.31.0 -apprise~=1.4.5 +apprise==1.5.0 beautifulsoup4~=4.12.2 pycountry~=22.3.5 -setuptools==68.0.0 \ No newline at end of file +setuptools==68.2.2 \ No newline at end of file diff --git a/setup.py b/setup.py index 8351b0a..0737950 100644 --- a/setup.py +++ b/setup.py @@ -2,11 +2,11 @@ setup( name='asu-notifier', - version='0.4.1', + version='0.4.2', packages=[''], url='https://github.com/Geek-MD/apple-security-updates-notifier', license='MIT', author='Geek-MD', author_email='edison.montes@gmail.com', - description='Python script that checks for Apple software updates, and notifies via Telegram Bot' + description='Python program that checks for Apple software updates, and notifies via Telegram Bot' ) diff --git a/start.sh b/start.sh new file mode 100644 index 0000000..329e02f --- /dev/null +++ b/start.sh @@ -0,0 +1,5 @@ +#!/bin/sh +# filename: asu-notifier.sh +pip install -r requirements.txt > requirements.log +path="$PWD" +python3 "$path"/asu-notifier.py -h \ No newline at end of file