diff --git a/ephemetoot.scheduler.plist b/ephemetoot.scheduler.plist deleted file mode 100644 index b61583f..0000000 --- a/ephemetoot.scheduler.plist +++ /dev/null @@ -1,27 +0,0 @@ - - - - - Label - ephemetoot.scheduler - WorkingDirectory - /FILEPATH/ephemetoot - ProgramArguments - - /usr/local/bin/ephemetoot - --config - /FILEPATH/config.yaml - - StandardOutPath - ephemetoot.log - StandardErrorPath - ephemetoot.error.log - StartCalendarInterval - - Hour - 9 - Minute - 00 - - - \ No newline at end of file diff --git a/lib/__init__.py b/ephemetoot/__init__.py similarity index 100% rename from lib/__init__.py rename to ephemetoot/__init__.py diff --git a/ephemetoot/console.py b/ephemetoot/console.py index 5d0f424..f4c05d1 100644 --- a/ephemetoot/console.py +++ b/ephemetoot/console.py @@ -51,6 +51,9 @@ parser.add_argument( parser.add_argument( "--hide-skipped", "--hide_skipped", action="store_true", help="Do not write to log when skipping saved toots" ) +parser.add_argument( + "--init", action="store_true", help="Initialise creation of a config file saved in the current directory." +) parser.add_argument( "--pace", action="store_true", help="Slow deletion actions to match API rate limit to avoid pausing" ) @@ -82,7 +85,9 @@ else: config_file = os.path.join( os.getcwd(), options.config ) def main(): - if options.version: + if options.init: + func.init() + elif options.version: func.version(vnum) elif options.schedule: func.schedule(options) diff --git a/ephemetoot/ephemetoot.py b/ephemetoot/ephemetoot.py index b6f67d1..afb4310 100644 --- a/ephemetoot/ephemetoot.py +++ b/ephemetoot/ephemetoot.py @@ -1,5 +1,12 @@ +# standard library from datetime import date, datetime, timedelta, timezone import json +import os +import subprocess +import sys +import time + +# third party from mastodon import ( Mastodon, MastodonError, @@ -7,12 +14,127 @@ from mastodon import ( MastodonNetworkError, MastodonRatelimitError, ) -import os import requests -import subprocess -import sys -import time +# local +from ephemetoot import plist + +def init(): + + init_start = "\033[96m" + init_end = "\033[0m" + init_eg = "\033[2m" + + conf_token = "" + while len(conf_token) < 1: + conf_token = input(init_start + "Access token: " + init_end) + + conf_user = "" + while len(conf_user) < 1: + conf_user = input( + init_start + + "Username" + + init_eg + + "(without the '@' - e.g. alice):" + + init_end + ) + + conf_url = "" + while len(conf_url) < 1: + conf_url = input( + init_start + + "Base URL" + + init_eg + + "(e.g. example.social):" + + init_end + ) + + conf_days = "" + while conf_days.isdigit() == False: + conf_days = input( + init_start + + "Days to keep" + + init_eg + + "(default 365):" + + init_end + ) + + conf_keep_pinned = "" + while conf_keep_pinned not in ["y", "n"]: + conf_keep_pinned = input( + init_start + + "Keep pinned toots?" + + init_eg + + "(y or n):" + + init_end + ) + + conf_pinned = "true" if conf_keep_pinned == "y" else "false" + + conf_keep_toots = input( + init_start + + "Toots to keep" + + init_eg + + " (optional list of IDs separated by commas):" + + init_end + ) + + conf_keep_hashtags = input( + init_start + + "Hashtags to keep" + + init_eg + + " (optional list separated by commas):" + + init_end + ) + + conf_keep_visibility = input( + init_start + + "Visibility to keep" + + init_eg + + " (optional list separated by commas):" + + init_end + ) + + conf_archive = input( + init_start + + "Archive path" + + init_eg + + " (optional filepath for archive):" + + init_end + ) + + # write out the config file + with open("config.yaml", "w") as configfile: + + configfile.write("-") + configfile.write("\n access_token: " + conf_token) + configfile.write("\n username: " + conf_user) + configfile.write("\n base_url: " + conf_url) + configfile.write("\n days_to_keep: " + conf_days) + configfile.write("\n keep_pinned: " + conf_pinned) + + if len(conf_keep_toots) > 0: + keep_list = conf_keep_toots.split(',') + configfile.write("\n toots_to_keep:") + for toot in keep_list: + configfile.write("\n - " + toot.strip()) + + if len(conf_keep_hashtags) > 0: + tag_list = conf_keep_hashtags.split(',') + configfile.write("\n hashtags_to_keep:") + for tag in tag_list: + configfile.write("\n - " + tag.strip()) + + if len(conf_keep_visibility) > 0: + viz_list = conf_keep_visibility.split(',') + configfile.write("\n visibility_to_keep:") + for mode in viz_list: + configfile.write("\n - " + mode.strip()) + + if len(conf_archive) > 0: + configfile.write("\n archive: " + conf_archive) + + configfile.close() def version(vnum): try: @@ -23,61 +145,55 @@ def version(vnum): latest_version = res["name"] print("\nephemetoot ==> ๐Ÿฅณ ==> ๐Ÿงผ ==> ๐Ÿ˜‡") print("-------------------------------") - print("You are using \033[92mVersion " + vnum + "\033[0m") - print("Latest release: \033[92m" + latest_version + "\033[0m\n") + print("Using: \033[92mVersion " + vnum + "\033[0m") + print("Latest: \033[92m" + latest_version + "\033[0m") + print("To upgrade to the most recent version run \033[92mpip3 install --update ephemetoot\033[0m") except Exception as e: print("Something went wrong:") - print(e) - def schedule(options): try: - with open(options.schedule + "/ephemetoot.scheduler.plist", "r") as file: - lines = file.readlines() - if options.schedule == ".": - working_dir = os.getcwd() + if options.schedule == ".": + working_dir = os.getcwd() + else: + working_dir = options.schedule - else: - working_dir = options.schedule - - lines[7] = " " + working_dir + "\n" - lines[10] = " " + sys.argv[0] + "\n" - lines[12] = " " + working_dir + "/config.yaml\n" + lines = plist.default_file.splitlines() + lines[7] = " " + working_dir + "" + lines[10] = " " + sys.argv[0] + "" + lines[12] = " " + working_dir + "/config.yaml" + lines[15] = " " + working_dir + "/ephemetoot.log" + lines[17] = " " + working_dir + "/ephemetoot.error.log" if options.time: - lines[21] = " " + options.time[0] + "\n" - lines[23] = " " + options.time[1] + "\n" - - with open("ephemetoot.scheduler.plist", "w") as file: - file.writelines(lines) + lines[21] = " " + options.time[0] + "" + lines[23] = " " + options.time[1] + "" + # write out file directly to ~/Library/LaunchAgents + f = open(os.path.expanduser("~/Library/LaunchAgents/") + "ephemetoot.scheduler.plist", mode="w") + for line in lines: + if line == lines[-1]: + f.write(line) + else: + f.write(line + "\n") + f.close() sys.tracebacklimit = 0 # suppress Tracebacks - # save the plist file into ~/Library/LaunchAgents - subprocess.run( - [ - "cp " - + options.schedule - + "/ephemetoot.scheduler.plist" - + " ~/Library/LaunchAgents/" - ], - shell=True, - ) # unload any existing file (i.e. if this is an update to the file) and suppress any errors subprocess.run( ["launchctl unload ~/Library/LaunchAgents/ephemetoot.scheduler.plist"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, - shell=True, + shell=True ) - # load the new file and suppress any errors + # load the new file subprocess.run( ["launchctl load ~/Library/LaunchAgents/ephemetoot.scheduler.plist"], - shell=True, + shell=True ) print("โฐ Scheduled!") - except Exception: + except Exception as e: print("๐Ÿ™ Scheduling failed.") diff --git a/ephemetoot/plist.py b/ephemetoot/plist.py new file mode 100644 index 0000000..23d9a83 --- /dev/null +++ b/ephemetoot/plist.py @@ -0,0 +1,27 @@ +default_file = ''' + + + + Label + ephemetoot.scheduler + WorkingDirectory + /FILEPATH/ephemetoot + ProgramArguments + + /usr/local/bin/ephemetoot + --config + config.yaml + + StandardOutPath + ephemetoot.log + StandardErrorPath + ephemetoot.error.log + StartCalendarInterval + + Hour + 9 + Minute + 00 + + +''' \ No newline at end of file diff --git a/lib/ephemetoot.py b/lib/ephemetoot.py deleted file mode 100644 index ad72598..0000000 --- a/lib/ephemetoot.py +++ /dev/null @@ -1,479 +0,0 @@ -from datetime import date, datetime, timedelta, timezone -import json -from mastodon import ( - Mastodon, - MastodonError, - MastodonAPIError, - MastodonNetworkError, - MastodonRatelimitError, -) -import os -import requests -import subprocess -import sys -import time - -def config(): - # TODO: this function should run through the config options and then create a config file - -def version(vnum): - try: - latest = requests.get( - "https://api.github.com/repos/hughrun/ephemetoot/releases/latest" - ) - res = latest.json() - latest_version = res["name"] - print("\nephemetoot ==> ๐Ÿฅณ ==> ๐Ÿงผ ==> ๐Ÿ˜‡") - print("-------------------------------") - print("You are using \033[92mVersion " + vnum + "\033[0m") - print("Latest release: \033[92m" + latest_version + "\033[0m\n") - - except Exception as e: - print("Something went wrong:") - print(e) - - -def schedule(options): - # TODO: change this so it just writes out the entire file from here direct to the ~/Library - try: - with open(options.schedule + "/ephemetoot.scheduler.plist", "r") as file: - lines = file.readlines() - - if options.schedule == ".": - working_dir = os.getcwd() - - else: - working_dir = options.schedule - - lines[7] = " " + working_dir + "\n" - lines[10] = " " + sys.argv[0] + "\n" - lines[12] = " " + working_dir + "/config.yaml\n" - - if options.time: - lines[21] = " " + options.time[0] + "\n" - lines[23] = " " + options.time[1] + "\n" - - with open("ephemetoot.scheduler.plist", "w") as file: - file.writelines(lines) - - sys.tracebacklimit = 0 # suppress Tracebacks - # save the plist file into ~/Library/LaunchAgents - subprocess.run( - [ - "cp " - + options.schedule - + "/ephemetoot.scheduler.plist" - + " ~/Library/LaunchAgents/" - ], - shell=True, - ) - # unload any existing file (i.e. if this is an update to the file) and suppress any errors - subprocess.run( - ["launchctl unload ~/Library/LaunchAgents/ephemetoot.scheduler.plist"], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - shell=True, - ) - # load the new file and suppress any errors - subprocess.run( - ["launchctl load ~/Library/LaunchAgents/ephemetoot.scheduler.plist"], - shell=True, - ) - print("โฐ Scheduled!") - except Exception: - print("๐Ÿ™ Scheduling failed.") - - -def checkToots(config, options, retry_count=0): - - keep_pinned = "keep_pinned" in config and config["keep_pinned"] - toots_to_keep = config["toots_to_keep"] if "toots_to_keep" in config else [] - visibility_to_keep = ( - config["visibility_to_keep"] if "visibility_to_keep" in config else [] - ) - hashtags_to_keep = ( - set(config["hashtags_to_keep"]) if "hashtags_to_keep" in config else set() - ) - days_to_keep = config["days_to_keep"] if "days_to_keep" in config else 365 - - try: - print( - "Fetching account details for @" - + config["username"] - + "@" - + config["base_url"] - ) - - def jsondefault(obj): - if isinstance(obj, (date, datetime)): - return obj.isoformat() - - def checkBatch(timeline, deleted_count=0): - for toot in timeline: - if "id" in toot and "archive" in config: - - # define archive path - if config["archive"][0] == "~": - archive_path = os.path.expanduser(config["archive"]) - elif config["archive"][0] == "/": - archive_path = config["archive"] - else: - archive_path = os.path.join(os.getcwd(), config["archive"]) - if archive_path[-1] != "/": - archive_path += "/" - - filename = os.path.join(archive_path, str(toot["id"]) + ".json") - - if not options.archive_deleted: - # write toot to archive - with open(filename, "w") as f: - f.write(json.dumps(toot, indent=4, default=jsondefault)) - f.close() - - toot_tags = set() - for tag in toot.tags: - toot_tags.add(tag.name) - try: - if keep_pinned and hasattr(toot, "pinned") and toot.pinned: - if not (options.hide_skipped or options.quiet): - if options.datestamp: - print( - str( - datetime.now(timezone.utc).strftime( - "%a %d %b %Y %H:%M:%S %z" - ) - ), - end=" : ", - ) - - print("๐Ÿ“Œ skipping pinned toot - " + str(toot.id)) - elif toot.id in toots_to_keep: - if not (options.hide_skipped or options.quiet): - if options.datestamp: - print( - str( - datetime.now(timezone.utc).strftime( - "%a %d %b %Y %H:%M:%S %z" - ) - ), - end=" : ", - ) - - print("๐Ÿ’พ skipping saved toot - " + str(toot.id)) - elif toot.visibility in visibility_to_keep: - if not (options.hide_skipped or options.quiet): - if options.datestamp: - print( - str( - datetime.now(timezone.utc).strftime( - "%a %d %b %Y %H:%M:%S %z" - ) - ), - end=" : ", - ) - - print( - "๐Ÿ‘€ skipping " - + toot.visibility - + " toot - " - + str(toot.id) - ) - elif len(hashtags_to_keep.intersection(toot_tags)) > 0: - if not (options.hide_skipped or options.quiet): - if options.datestamp: - print( - str( - datetime.now(timezone.utc).strftime( - "%a %d %b %Y %H:%M:%S %z" - ) - ), - end=" : ", - ) - - print("#๏ธโƒฃ skipping toot with hashtag - " + str(toot.id)) - elif cutoff_date > toot.created_at: - if hasattr(toot, "reblog") and toot.reblog: - if not options.quiet: - if options.datestamp: - print( - str( - datetime.now(timezone.utc).strftime( - "%a %d %b %Y %H:%M:%S %z" - ) - ), - end=" : ", - ) - - print( - "๐Ÿ‘Ž unboosting toot " - + str(toot.id) - + " boosted " - + toot.created_at.strftime("%d %b %Y") - ) - deleted_count += 1 - # unreblog the original toot (their toot), not the toot created by boosting (your toot) - if not options.test: - if mastodon.ratelimit_remaining == 0: - if not options.quiet: - print( - "Rate limit reached. Waiting for a rate limit reset" - ) - # check for --archive-deleted - if ( - options.archive_deleted - and "id" in toot - and "archive" in config - ): - # write toot to archive - with open(filename, "w") as f: - f.write( - json.dumps( - toot, indent=4, default=jsondefault - ) - ) - f.close() - mastodon.status_unreblog(toot.reblog) - else: - if not options.quiet: - if options.datestamp: - print( - str( - datetime.now(timezone.utc).strftime( - "%a %d %b %Y %H:%M:%S %z" - ) - ), - end=" : ", - ) - - print( - "โŒ deleting toot " - + str(toot.id) - + " tooted " - + toot.created_at.strftime("%d %b %Y") - ) - deleted_count += 1 - time.sleep( - 2 - ) # wait 2 secs between deletes to be a bit nicer to the server - if not options.test: - if ( - mastodon.ratelimit_remaining == 0 - and not options.quiet - ): - - now = time.time() - diff = mastodon.ratelimit_reset - now - - print( - "\nRate limit reached at " - + str( - datetime.now(timezone.utc).strftime( - "%a %d %b %Y %H:%M:%S %z" - ) - ) - + " - next reset due in " - + str(format(diff / 60, ".0f")) - + " minutes.\n" - ) - # check for --archive-deleted - if ( - options.archive_deleted - and "id" in toot - and "archive" in config - ): - # write toot to archive - with open(filename, "w") as f: - f.write( - json.dumps( - toot, indent=4, default=jsondefault - ) - ) - f.close() - - mastodon.status_delete(toot) - - except MastodonRatelimitError: - - now = time.time() - diff = mastodon.ratelimit_reset - now - - print( - "\nRate limit reached at " - + str( - datetime.now(timezone.utc).strftime( - "%a %d %b %Y %H:%M:%S %z" - ) - ) - + " - waiting for next reset due in " - + str(format(diff / 60, ".0f")) - + " minutes.\n" - ) - - time.sleep(diff + 1) # wait for rate limit to reset - - except MastodonError as e: - - def retry_on_error(attempts): - - if attempts < 6: - try: - if not options.quiet: - print( - "Attempt " - + str(attempts) - + " at " - + str( - datetime.now(timezone.utc).strftime( - "%a %d %b %Y %H:%M:%S %z" - ) - ) - ) - mastodon.status_delete(toot) - time.sleep( - 2 - ) # wait 2 secs between deletes to be a bit nicer to the server - except: - attempts += 1 - time.sleep(60 * options.retry_mins) - retry_on_error(attempts) - else: - raise TimeoutError("Gave up after 5 attempts") - - print( - "๐Ÿ›‘ ERROR deleting toot - " - + str(toot.id) - + " - " - + str(e.args[0]) - + " - " - + str(e.args[3]) - ) - if not options.quiet: - print( - "Waiting " - + str(options.retry_mins) - + " minutes before re-trying" - ) - time.sleep(60 * options.retry_mins) - retry_on_error(attempts=2) - - except KeyboardInterrupt: - print("Operation aborted.") - break - except KeyError as e: - print( - "โš ๏ธ There is an error in your config.yaml file. Please add a value for " - + str(e) - + " and try again." - ) - break - except: - e = sys.exc_info() - - print("๐Ÿ›‘ Unknown ERROR deleting toot - " + str(toot.id)) - - print("ERROR: " + str(e[0]) + " - " + str(e[1])) - - # the account_statuses call is paginated with a 40-toot limit - # get the id of the last toot to include as 'max_id' in the next API call. - # then keep triggering new rounds of checkToots() until there are no more toots to check - try: - max_id = timeline[-1:][0].id - next_batch = mastodon.account_statuses(user_id, limit=40, max_id=max_id) - if len(next_batch) > 0: - checkBatch(next_batch, deleted_count) - else: - if options.test: - if options.datestamp: - print( - "\n\n" - + str( - datetime.now(timezone.utc).strftime( - "%a %d %b %Y %H:%M:%S %z" - ) - ), - end=" : ", - ) - - print( - "Test run completed. This would have removed " - + str(deleted_count) - + " toots." - ) - else: - if options.datestamp: - print( - "\n\n" - + str( - datetime.now(timezone.utc).strftime( - "%a %d %b %Y %H:%M:%S %z" - ) - ), - end=" : ", - ) - - print("Removed " + str(deleted_count) + " toots.") - - if not options.quiet: - print("\n---------------------------------------") - print("๐Ÿฅณ ==> ๐Ÿงผ ==> ๐Ÿ˜‡ User cleanup complete!") - print("---------------------------------------\n") - - except IndexError: - print("No toots found!") - - except Exception as e: - print("ERROR: " + str(e.args[0])) - - if options.pace: - mastodon = Mastodon( - access_token=config["access_token"], - api_base_url="https://" + config["base_url"], - ratelimit_method="pace", - ) - - else: - - mastodon = Mastodon( - access_token=config["access_token"], - api_base_url="https://" + config["base_url"], - ratelimit_method="wait", - ) - - # STARTS HERE - cutoff_date = datetime.now(timezone.utc) - timedelta(days=days_to_keep) - user_id = mastodon.account_verify_credentials().id - account = mastodon.account(user_id) - timeline = mastodon.account_statuses(user_id, limit=40) - - if not options.quiet: - print("Checking " + str(account.statuses_count) + " toots") - - checkBatch(timeline) - - except KeyError as val: - print("\nโš ๏ธ error with in your config.yaml file!") - print("Please ensure there is a value for " + str(val) + "\n") - - except MastodonAPIError as e: - if e.args[1] == 401: - print("\n๐Ÿ™… User and/or access token does not exist or has been deleted (401)") - elif e.args[1] == 404: - print("\n๐Ÿ”ญ Can't find that server (404)") - else: - print("\n๐Ÿ˜• Server has returned an error (5xx)") - - except MastodonNetworkError: - if retry_count == 0: - print("\n๐Ÿ“ก ephemetoot cannot connect to the server - are you online?") - if retry_count < 4: - print( - "Waiting " - + str(options.retry_mins) - + " minutes before trying again" - ) - time.sleep(60 * options.retry_mins) - retry_count += 1 - print("Attempt " + str(retry_count + 1)) - checkToots(config, options, retry_count) - else: - print("Gave up waiting for network")