diff --git a/bot.py b/bot.py index c3612d9..7d01f05 100644 --- a/bot.py +++ b/bot.py @@ -3,20 +3,21 @@ Copyright © twilsonco 2020 Description: This is a discord bot to manage torrent transfers through the Transmission transmissionrpc python library. -Version: 1.1 +Version: 1.2 """ - + import discord import asyncio import aiohttp import json +from json import dumps, load import subprocess from discord.ext.commands import Bot from discord.ext import commands from platform import python_version import os import sys -from os.path import expanduser, join +from os.path import expanduser, join, exists, isdir, isfile import re import datetime import pytz @@ -25,35 +26,203 @@ import secrets import transmissionrpc import logging import base64 +import random from enum import Enum # BEGIN USER CONFIGURATION -BOT_PREFIX = 't/' -TOKEN = 'SECRET_TOKEN' -OWNERS = [OWNER_USER_IDS] -BLACKLIST = [USER_IDS] -WHITELIST = [USER_IDS] -CHANNEL_IDS=[CHANNEL_IDS] -LOGO_URL="https://iyanovich.files.wordpress.com/2009/04/transmission-logo.png" +CONFIG_DIR = os.path.dirname(os.path.realpath(__file__)) -TSCLIENT_CONFIG={ - 'host': "10.0.1.2", - 'port': 9091, - 'user': "USERNAME", - 'password': "PASSWORD" +""" +Bot configuration: +1. set this to you liking +2. run the bot, which will make a config.json file containing this configuration info +3. comment or remove the configuration below, as the config.json will be used instead (this also makes updating to new versions easier) +""" +CONFIG = { + "tsclient": { # information for transmission remote web gui + 'host': "192.168.0.2", + 'port': 9091, + 'user': "USERNAME", + 'password': "PASSWORD" + }, + "whitelist_user_ids": [], # discord users allowed to use bot + "blacklist_user_ids": [], # discord users disallowed to use bot + "owner_user_ids": [], # discord users given full access + "private_transfers_protected": True, # prevent transfers on private trackers from being removed + "bot_prefix": "t/", # bot command prefix + "bot_token": "BOT_TOKEN", # bot token + "dryrun": False, # if true, no changes are actually applied to transfers + "listen_channel_ids": [], # channels in which to listen for commands + "listen_all_channels": False, # if true, listen for commands in all text channels + "listen_DMs": True, # listen for commands via DM to the bot + "logo_url": "https://iyanovich.files.wordpress.com/2009/04/transmission-logo.png", # URL to logo that appears in some output + "notification_channel_id": 'NOTIFICATION_CHANNEL_ID', # channel to which in-channel notificatations will be posted + "notification_enabled": True, # if False, in-channel and DM notifications are disabled + "notification_enabled_in_channel": True, # if False, in-channel notifications are disabled, but DM notifications will still work + "notification_freq": 300, # number of seconds between checking transfers and posting notifications + "notification_reaction_check_factor": 2, # determines how long DM notification subscription reactions will be monitored on in-channel and DM notifications; they're monitored for (notification_reaction_check_factor X notification_freq) seconds + "notification_DM_opt_out_user_ids": [], # DON'T MODIFY (used by bot to record users that have opted out of receiving DM notifications) + "notification_states":{ # determines the types of transfer state changes that are reported in notifications... + "in_channel": # ...for in-channel notifications, (this is the full list of potential state changes) + [ + "new", + "removed", + "error", + "downloaded", + "stalled", + "unstalled", + "finished", + "stopped", + "started" + ], + "notified_users": # ...DM notifications for users that opted in to DM notifications for transfer(s) + [ + "removed", + "error", + "downloaded", + "stalled", + "unstalled", + "finished", + "stopped", + "started" + ], + "added_user":# ...and DM notifications to users that added transfers + [ + "removed", + "error", + "downloaded", + "stalled", + "unstalled", + "finished", + "stopped", + "started" + ] + }, + "repeat_cancel_verbose": True, # if true, print message when auto-update is canceled for a message + "repeat_freq": 2, # number of seconds between updating an auto-update message + "repeat_timeout": 3600, # number of seconds before an auto-update message times out + "repeat_timeout_verbose": True, # if true, print message when auto-update message times out + "summary_num_top_ratio": 5 # number of top seed-ratio transfers to show at the bottom of the summary output } -DRYRUN = False -REPEAT_FREQ = 2 # time in seconds to wait between reprinting repeated commands (in addition to the time requred to delete old message(s) and add reactions) -REPEAT_TIMEOUT = 3600 # time in seconds before a repeated command automatically stops -REPEAT_TIMEOUT_VERBOSE = True # if true, print a message when a repeated message times out -REPEAT_CANCEL_VERBOSE = True # same but for when user cancels a repeated message +TSCLIENT_CONFIG = None -logging.basicConfig(format='%(asctime)s %(message)s',filename=join(expanduser("~"),'transmissionbot.log')) +# logging.basicConfig(format='%(asctime)s %(message)s',filename=join(expanduser("~"),'ts_scripts.log')) +logging.basicConfig(format='%(asctime)s %(message)s',filename=join(CONFIG_DIR,'transmissionbot.log')) +logger = logging.getLogger('transmission_bot') +logger.setLevel(logging.INFO) # set according to table below. values LESS than the set value will be ignored + +""" +Level Numeric value +CRITICAL 50 +ERROR 40 +WARNING 30 +INFO 20 +DEBUG 10 +NOTSET 0 +""" # END USER CONFIGURATION +# for storing config and transfer list + +CONFIG_JSON = join(CONFIG_DIR, "config.json") +LOCK_FILE = join(CONFIG_DIR, "lock") + + + + +DEFAULT_REASON="TransmissionBot" + +def lock(lockfile=LOCK_FILE): + """ Wait for LOCK_FILE to not exist, then create it to lock """ + from time import sleep + from random import random + from pathlib import Path + lock_file = Path(lockfile) + + logger.debug("Creating lock file '{}'".format(lockfile)) + + while lock_file.is_file(): + logger.debug("Config file locked, waiting...") + sleep(0.5) + + logger.debug("Lock file created '{}'".format(lockfile)) + lock_file.touch() + +def unlock(lockfile=LOCK_FILE): + """ Delete LOCK_FILE """ + from pathlib import Path + lock_file = Path(lockfile) + + logger.debug("Removing lock file '{}'".format(lockfile)) + + if lock_file.is_file(): + lock_file.unlink() + logger.debug("Lock file removed '{}'".format(lockfile)) + else: + logger.debug("Lock file didn't exist '{}'".format(lockfile)) + +def mkdir_p(path): + """mimics the standard mkdir -p functionality when creating directories + + :param path: + :return: + """ + try: + makedirs(path) + except OSError as exc: # Python >2.5 + if exc.errno == errno.EEXIST and isdir(path): + pass + else: + raise + +def generate_json(json_data=None, path=None, overwrite=False): + """Generate a new config file based on the value of the CONFIG global variable. + + This function will cause a fatal error if trying to overwrite an exiting file + without setting overwrite to True. + + :param overwrite: Overwrite existing config file + :type overwrite: bool + :return: Create status + :rtype: bool + """ + if not path or not json_data: + return False + if exists(path) and not overwrite: + logger.fatal("JSON file exists already! (Set overwite option to overwrite)") + return False + if not exists(os.path.dirname(path)): + mkdir_p(os.path.dirname(path)) + with open(path, 'w') as cf: + lock() + cf.write(dumps(json_data, sort_keys=True, indent=4, separators=(',', ': '))) + unlock() + return True + + +def load_json(path=None): + """Load a config file from disk using the default location if it exists. If path is defined + it will be used instead of the default path. + + :param path: Optional path to config file + :type path: str + :return: Load status + :rtype: bool + """ + if not path: + return False + if exists(path): + jsonContents = load(open(path)) + logger.debug("Loaded JSON file: {}".format(path)) + return jsonContents + return False + + +CONFIG = load_json(CONFIG_JSON) if exists(CONFIG_JSON) else None # will be read from CONFIG_JSON + class OutputMode(Enum): AUTO = 1 DESKTOP = 2 @@ -72,22 +241,42 @@ REPEAT_MSGS = {} # 'content':content, # 'pin_to_bottom':False, # 'reprint': False, - # 'freq':REPEAT_FREQ, - # 'timeout':REPEAT_TIMEOUT, - # 'timeout_verbose':REPEAT_TIMEOUT_VERBOSE, - # 'cancel_verbose':REPEAT_CANCEL_VERBOSE, + # 'freq':CONFIG['repeat_freq'], + # 'timeout':CONFIG['repeat_timeout'], + # 'timeout_verbose':REPEAT_TIMEOUT, + # 'cancel_verbose':CONFIG['repeat_cancel_verbose'], # 'start_time':datetime.datetime.now(), # 'do_repeat':True # } +TORRENT_JSON = join(CONFIG_DIR, "transfers.json") -client = Bot(command_prefix=BOT_PREFIX) +# list of transfer information to be stored in a separate file, used for +# checking for transfer state stanges for the notification system +# here's the structure, a dict with a dict for each transfer with select information. +# this will be a local var, since it's only needed in the function that checks for changes. +# TORRENT_LIST = { +# 'hashString':{ +# 'name':t.name, +# 'error':t.error, +# 'errorString':t.errorString, +# 'status':t.status, +# 'isStalled':t.isStalled, +# 'progress':t.progress +# } +# } + +TORRENT_ADDED_USERS = {} +TORRENT_NOTIFIED_USERS = {} +TORRENT_OPTOUT_USERS = {} + +async def determine_prefix(bot, message): + return CONFIG['bot_prefix'] + +client = Bot(command_prefix=determine_prefix) TSCLIENT = None MAKE_CLIENT_FAILED = False -logger = logging.getLogger('transmission_bot') -logger.setLevel(logging.INFO) -DEFAULT_REASON="TransmissionBot" # Begin transmissionrpc functions, lovingly taken from https://github.com/leighmacdonald/transmission_scripts @@ -157,7 +346,9 @@ class TSClient(transmissionrpc.Client): torrents = [tor for tor in torrents if regex.search(tor.name)] if filter_by: for f in filter_by.split(): - if f in filter_names: + if f == "active": + torrents = [t for t in torrents if not t.isStalled and t.rateDownload + t.rateUpload == 0] + elif f in filter_names: torrents = filter_torrents_by(torrents, key=getattr(Filter, filter_by)) elif f == "verifying": torrents = [t for t in torrents if "check" in t.status] @@ -372,11 +563,11 @@ def remove_torrent(torrent, reason=DEFAULT_REASON, delete_files=False): :return: """ if torrent.status != "stopped": - if not DRYRUN: + if not CONFIG['dryrun']: TSCLIENT.stop_torrent(torrent.hashString) - if not DRYRUN: + if not CONFIG['dryrun']: TSCLIENT.remove_torrent(torrent.hashString, delete_data=delete_files) - logger.info("Removed: {} {}\n\tReason: {}\n\tDry run: {}, Delete files: {}".format(torrent.name, torrent.hashString, reason, DRYRUN,delete_files)) + logger.info("Removed: {} {}\n\tReason: {}\n\tDry run: {}, Delete files: {}".format(torrent.name, torrent.hashString, reason, CONFIG['dryrun'],delete_files)) def remove_torrents(torrents, reason=DEFAULT_REASON, delete_files=False): """ Remove a torrent from the client stopping it first if its in a started state. @@ -409,9 +600,9 @@ def stop_torrents(torrents=[], reason=DEFAULT_REASON): """ for torrent in (torrents if len(torrents) > 0 else TSCLIENT.get_torrents()): if torrent.status not in ["stopped","finished"]: - if not DRYRUN: + if not CONFIG['dryrun']: TSCLIENT.stop_torrent(torrent.hashString) - logger.info("Paused: {} {}\n\tReason: {}\n\tDry run: {}".format(torrent.name, torrent.hashString, reason, DRYRUN)) + logger.info("Paused: {} {}\n\tReason: {}\n\tDry run: {}".format(torrent.name, torrent.hashString, reason, CONFIG['dryrun'])) def resume_torrents(torrents=[], reason=DEFAULT_REASON, start_all=False): """ Stop (pause) a list of torrents from the client. @@ -427,15 +618,15 @@ def resume_torrents(torrents=[], reason=DEFAULT_REASON, start_all=False): :return: """ if start_all: - if not DRYRUN: + if not CONFIG['dryrun']: TSCLIENT.start_all() - logger.info("Resumed: all transfers\n\tReason: {}\n\tDry run: {}".format(torrent.name, torrent.hashString, reason, DRYRUN)) + logger.info("Resumed: all transfers\n\tReason: {}\n\tDry run: {}".format(torrent.name, torrent.hashString, reason, CONFIG['dryrun'])) else: for torrent in (torrents if len(torrents) > 0 else TSCLIENT.get_torrents()): if torrent.status == "stopped": - if not DRYRUN: + if not CONFIG['dryrun']: TSCLIENT.start_torrent(torrent.hashString) - logger.info("Resumed: {} {}\n\tReason: {}\n\tDry run: {}".format(torrent.name, torrent.hashString, reason, DRYRUN)) + logger.info("Resumed: {} {}\n\tReason: {}\n\tDry run: {}".format(torrent.name, torrent.hashString, reason, CONFIG['dryrun'])) def verify_torrents(torrents=[]): """ Verify a list of torrents from the client. @@ -450,9 +641,9 @@ def verify_torrents(torrents=[]): :return: """ for torrent in (torrents if len(torrents) > 0 else TSCLIENT.get_torrents()): - if not DRYRUN: + if not CONFIG['dryrun']: TSCLIENT.verify_torrent(torrent.hashString) - logger.info("Verified: {} {}\n\tDry run: {}".format(torrent.name, torrent.hashString, DRYRUN)) + logger.info("Verified: {} {}\n\tDry run: {}".format(torrent.name, torrent.hashString, CONFIG['dryrun'])) def add_torrent(torStr): tor = None @@ -465,22 +656,311 @@ def add_torrent(torStr): # async def status_task(): # while True: -# await client.change_presence(activity=discord.Game("{}help".format(BOT_PREFIX))) +# await client.change_presence(activity=discord.Game("{}help".format(CONFIG['bot_prefix']))) # await asyncio.sleep(86400) + +# check current transfers against those in TORRENT_JSON and print notifications to channel for certain changes +def check_for_transfer_changes(): + global TORRENT_NOTIFIED_USERS, TORRENT_ADDED_USERS, TORRENT_OPTOUT_USERS + # get current transfer information + reload_client() + torrents = TSCLIENT.get_torrents() + # TORRENT_LIST = { + # 'hashString':{ + # 'name':t.name, + # 'error':t.error, + # 'errorString':t.errorString, + # 'status':t.status, + # 'isStalled':t.isStalled, + # 'progress':t.progress + # } + # } + lock() + curTorrents = {t.hashString:{ + 'name':t.name, + 'error':t.error, + 'errorString':t.errorString, + 'status':t.status, + 'isStalled':t.isStalled, + 'progress':t.progress, + 'added_user':None if t.hashString not in TORRENT_ADDED_USERS else TORRENT_ADDED_USERS[t.hashString], + 'notified_users':[] if t.hashString not in TORRENT_NOTIFIED_USERS else TORRENT_NOTIFIED_USERS[t.hashString], + 'optout_users':[] if t.hashString not in TORRENT_OPTOUT_USERS else TORRENT_OPTOUT_USERS[t.hashString] + } for t in torrents} + if exists(TORRENT_JSON): + oldTorrents = load_json(path=TORRENT_JSON) + if len(curTorrents) > 0 and len(oldTorrents) > 0 and len(next(iter(curTorrents.values()))) != len(next(iter(oldTorrents.values()))): + logger.info("old transfer json {} is using an old format, replacing with current transfers and not checking for changes.".format(TORRENT_JSON)) + generate_json(json_data=curTorrents, path=TORRENT_JSON, overwrite=True) + return None + # get added_user and notified_users from oldTorrents and copy to newTorrents + for h,t in oldTorrents.items(): + if h in curTorrents: + if t['added_user']: + # this would overwrite a torrent that somehow had two added_users, but that should never happen + curTorrents[h]['added_user'] = t['added_user'] + if len(t['notified_users']) > 0: + curTorrents[h]['notified_users'] += [u for u in t['notified_users'] if u not in curTorrents[h]['notified_users']] + if len(t['optout_users']) > 0: + curTorrents[h]['optout_users'] += [u for u in t['optout_users'] if u not in curTorrents[h]['optout_users'] and (h not in TORRENT_NOTIFIED_USERS or u not in TORRENT_NOTIFIED_USERS[h])] + # logger.debug("'optout_users' for {} ({}): {}".format(t['name'], h, str(t['optout_users']))) + # for u in t['optout_users']: + # if h in TORRENT_NOTIFIED_USERS and u in TORRENT_NOTIFIED_USERS[h]: + # user = client.get_user(u) + # logger.debug("Removing {} ({}) from 'optout_users' for {} ({})".format(user.name, u, t['name'], h)) + # curTorrents[h]['optout_users'].remove(u) + # logger.debug("new 'optout_users' for {} ({}): {}".format(t['name'], h, str(curTorrents[h]['optout_users']))) + + TORRENT_NOTIFIED_USERS = {} + TORRENT_ADDED_USERS = {} + TORRENT_OPTOUT_USERS = {} + unlock() + generate_json(json_data=curTorrents, path=TORRENT_JSON, overwrite=True) + else: + TORRENT_NOTIFIED_USERS = {} + TORRENT_ADDED_USERS = {} + TORRENT_OPTOUT_USERS = {} + unlock() + generate_json(json_data=curTorrents, path=TORRENT_JSON, overwrite=True) + return None + + + # print("before checking") + # get lists of different transfer changes + removedTransfers = {h:t for h,t in oldTorrents.items() if h not in curTorrents} + errorTransfers = {h:t for h,t in curTorrents.items() if t['error'] != 0 and ((h in oldTorrents and oldTorrents[h]['error'] == 0) or h not in oldTorrents)} + downloadedTransfers = {h:t for h,t in curTorrents.items() if t['progress'] == 100.0 and ((h in oldTorrents and oldTorrents[h]['progress'] < 100.0) or h not in oldTorrents)} + stalledTransfers = {h:t for h,t in curTorrents.items() if t['isStalled'] and ((h in oldTorrents and not oldTorrents[h]['isStalled']) or h not in oldTorrents)} + unstalledTransfers = {h:t for h,t in curTorrents.items() if not t['isStalled'] and h in oldTorrents and oldTorrents[h]['isStalled']} + finishedTransfers = {h:t for h,t in curTorrents.items() if t['status'] == 'finished' and ((h in oldTorrents and oldTorrents[h]['status'] != 'finished') or h not in oldTorrents)} + stoppedTransfers = {h:t for h,t in curTorrents.items() if t['status'] == 'stopped' and ((h in oldTorrents and oldTorrents[h]['status'] != 'stopped') or h not in oldTorrents)} + startedTransfers = {h:t for h,t in curTorrents.items() if t['status'] in ['downloading','seeding'] and h in oldTorrents and oldTorrents[h]['status'] not in ['downloading','seeding']} + + # only report transfers as "new" if they haven't already been put in one of the dicts above + checkTransfers = {**errorTransfers, **downloadedTransfers, **stalledTransfers, **unstalledTransfers, **finishedTransfers, **stoppedTransfers, **startedTransfers, **oldTorrents} + newTransfers = {h:t for h,t in curTorrents.items() if h not in checkTransfers} + + + # print("done checking for changes") + + # DEBUG grab a few random transfers for each type, vary the number to see if multiple embeds works + # print(str(oldTorrents)) + # numTransfers = 3 + # removedTransfers = {h:t for h,t in random.sample(oldTorrents.items(),numTransfers)} + # errorTransfers = {h:t for h,t in random.sample(curTorrents.items(),numTransfers)} + # downloadedTransfers = {h:t for h,t in random.sample(curTorrents.items(),numTransfers)} + # stalledTransfers = {h:t for h,t in random.sample(curTorrents.items(),numTransfers)} + # unstalledTransfers = {h:t for h,t in random.sample(curTorrents.items(),numTransfers)} + # finishedTransfers = {h:t for h,t in random.sample(curTorrents.items(),numTransfers)} + # newTransfers = {h:t for h,t in random.sample(curTorrents.items(),numTransfers)} + # print(str(errorTransfers)) + # print("done applying debug changes") + + return { + 'new':{'name':"🟢 {0} new transfer{1}", 'data':newTransfers}, + 'removed':{'name':"❌ {0} removed transfer{1}", 'data':removedTransfers}, + 'error':{'name':"‼️ {0} transfer{1} with error{1}", 'data':errorTransfers}, + 'downloaded':{'name':"⬇️ {0} transfer{1} finished downloading", 'data':downloadedTransfers}, + 'stalled':{'name':"🐢 {0} transfer{1} stalled", 'data':stalledTransfers}, + 'unstalled':{'name':"🐇 {0} previously stalled transfer{1} now active", 'data':unstalledTransfers}, + 'finished':{'name':"🏁 {0} transfer{1} finished downloading and seeding", 'data':finishedTransfers}, + 'stopped':{'name':"⏹ {0} transfer{1} paused", 'data':stoppedTransfers}, + 'started':{'name':"▶️ {0} transfer{1} resumed", 'data':startedTransfers} + } + +def prepare_notifications(changedTransfers, states=CONFIG['notification_states']['in_channel']): + nTotal = sum([len(d['data']) for s,d in changedTransfers.items() if s in states]) if changedTransfers is not None else 0 + torrents = {} + if nTotal > 0: + embeds = [discord.Embed(title="")] + ts = datetime.datetime.now(tz=pytz.timezone('America/Denver')) + embeds[-1].timestamp = ts + for s,d in changedTransfers.items(): + if s in states: + n = len(d['data']) + if n > 0: + for h,t in d['data'].items(): + torrents[h] = t + + nameStr = d['name'].format(n, '' if n == 1 else 's') + valStr = ',\n'.join(["{}{}".format("**{}.**".format(i+1) if n > 1 else '', t['name'], "\n (error: *{}*)".format(t['errorString']) if t['errorString'] != "" else "") for i,t in enumerate(d['data'].values())]) + + if len(embeds[-1]) + len(nameStr) + len(valStr) >= 6000: + embeds.append(discord.Embed(title="")) + embeds[-1].timestamp = ts + + embeds[-1].add_field(name=nameStr, value=valStr, inline=False) + return embeds, nTotal, torrents + return None, nTotal, torrents + +async def check_notification_reactions(message, is_text_channel, torrents, starttime=datetime.datetime.now()): + if (datetime.datetime.now() - starttime).total_seconds() >= CONFIG['notification_freq'] * CONFIG['notification_reaction_check_factor']: + if is_text_channel: + await message.clear_reactions() + return + + def check(reaction, user): + return user.id in CONFIG['whitelist_user_ids'] and reaction.message.id == message.id and (str(reaction.emoji) == '🔕' or (str(reaction.emoji) == '🔔' and is_text_channel)) + + try: + reaction, user = await client.wait_for('reaction_add', timeout=CONFIG['notification_freq'], check=check) + except asyncio.TimeoutError: + return await check_notification_reactions(message, is_text_channel, torrents, starttime=starttime) + else: + if str(reaction.emoji) == '🔔': + if len(torrents) > 0: + for h,t in torrents.items(): + if h in TORRENT_NOTIFIED_USERS: + TORRENT_NOTIFIED_USERS[h].append(user.id) + else: + TORRENT_NOTIFIED_USERS[h] = [user.id] + embed = discord.Embed(title="🔔 Notifications enabled for:", description=",\n".join(["{}{}".format("" if len(torrents) == 1 else "**{}.**".format(i+1),j) for i,j in enumerate([t['name'] for t in torrents.values()])])) + await user.send(embed=embed) + if str(reaction.emoji) == '🔕': + if len(torrents) > 0: + for h,t in torrents.items(): + if h in TORRENT_OPTOUT_USERS: + TORRENT_OPTOUT_USERS[h].append(user.id) + else: + TORRENT_OPTOUT_USERS[h] = [user.id] + embed = discord.Embed(title="🔕 Notifications disabled for:", description=",\n".join(["{}{}".format("" if len(torrents) == 1 else "**{}.**".format(i+1),j) for i,j in enumerate([t['name'] for t in torrents.values()])])) + await user.send(embed=embed) + return await check_notification_reactions(message, is_text_channel, torrents, starttime=starttime) + +async def run_notifications(): + if CONFIG['notification_enabled']: + # get all changes + logger.debug("Running notification check") + changedTransfers = check_for_transfer_changes() + nTotal = sum([len(d['data']) for d in changedTransfers.values()]) if changedTransfers is not None else 0 + if nTotal > 0: + + # first in_channel notifications + if CONFIG['notification_enabled_in_channel']: + embeds, n, torrents = prepare_notifications(changedTransfers, CONFIG['notification_states']['in_channel']) + logger.debug("in_channel notifications: {}".format(n)) + # now post notifications + if n > 0: + ch = client.get_channel(CONFIG['notification_channel_id']) + msgs = [await ch.send(embed=e) for e in embeds] + [await msgs[-1].add_reaction(s) for s in ['🔔','🔕']] + asyncio.create_task(check_notification_reactions(msgs[-1], True, torrents, datetime.datetime.now())) + + + # Now notify the users + # First get only the changedTransfers that require user notification. + # These will be stored separate because users *should* be reminded whether a notification + # is for a torrent they added versus one they elected to receive notifications for. + logger.debug("preparing list of transfers for user DM notifications") + + addedUserChangedTransfers = {} + notifiedUserChangedTransfers = {} + for s,d in changedTransfers.items(): + logger.debug("state: {} ({} transfers)".format(s, len(d['data']))) + if s in CONFIG['notification_states']['added_user']: + for h,t in d['data'].items(): + logger.debug("Checking transfer: {} ({})".format(str(t), h)) + if t['added_user'] is not None and t['added_user'] not in t['optout_users']: + u = t['added_user'] + if u in addedUserChangedTransfers: + if s in addedUserChangedTransfers[u]: + addedUserChangedTransfers[u][s]['data'][h] = t + else: + addedUserChangedTransfers[u][s] = {'name':d['name'],'data':{h:t}} + else: + addedUserChangedTransfers[u] = {s:{'name':d['name'],'data':{h:t}}} + if s in CONFIG['notification_states']['notified_users']: + for h,t in d['data'].items(): + logger.debug("Checking transfer: {} ({})".format(str(t), h)) + for u in t['notified_users']: + if u not in t['optout_users'] and (u not in addedUserChangedTransfers or s not in addedUserChangedTransfers[u] or h not in addedUserChangedTransfers[u][s]['data']): + if u in notifiedUserChangedTransfers: + if s in notifiedUserChangedTransfers[u]: + notifiedUserChangedTransfers[u][s]['data'][h] = t + else: + notifiedUserChangedTransfers[u][s] = {'name':d['name'],'data':{h:t}} + else: + notifiedUserChangedTransfers[u] = {s:{'name':d['name'],'data':{h:t}}} + + logger.debug("DM notifications for notified_users: {}".format(str(notifiedUserChangedTransfers))) + logger.debug("DM notifications for added_user: {}".format(str(addedUserChangedTransfers))) + logger.debug("done preparing list of user DM notifications, now send notifications") + + # now send notifications as DMs + for u,transfers in addedUserChangedTransfers.items(): + logger.debug("Sending added_user notificaions for user {}".format(u)) + embeds, n, torrents = prepare_notifications(transfers, CONFIG['notification_states']['added_user']) + if n > 0: + embeds[-1].set_author(name="Activity for transfer{} you added".format('' if n == 1 else 's')) + user = client.get_user(u) + msgs = [await user.send(embed=e) for e in embeds] + await msgs[-1].add_reaction('🔕') + asyncio.create_task(check_notification_reactions(msgs[-1], False, torrents, datetime.datetime.now())) + for u,transfers in notifiedUserChangedTransfers.items(): + logger.debug("Sending notified_user notificaions for user {}".format(u)) + embeds, n, torrents = prepare_notifications(transfers, CONFIG['notification_states']['notified_users']) + if n > 0: + user = client.get_user(u) + msgs = [await user.send(embed=e) for e in embeds] + await msgs[-1].add_reaction('🔕') + asyncio.create_task(check_notification_reactions(msgs[-1], False, torrents, datetime.datetime.now())) + else: + logger.debug("No changed transfers...") + + return + +async def loop_notifications(): + while CONFIG['notification_enabled']: + # print("looping notifications") + await run_notifications() + await asyncio.sleep(CONFIG['notification_freq']) + return + + @client.event async def on_ready(): + global TSCLIENT_CONFIG, CONFIG + + TSCLIENT_CONFIG = CONFIG['tsclient'] + if not CONFIG: # load from config file + CONFIG = load_json(path=CONFIG_JSON) + if not CONFIG: + logger.critical("Failed to load config from {}".format(CONFIG_JSON)) + await client.change_presence(activity=discord.Game("config load error!")) + return + else: # config specified in this file, so try to write config file + if exists(CONFIG_JSON): + if load_json(CONFIG_JSON) != CONFIG: + # check current config against config file, throw error if different + logger.critical("Conflict: Config file exists and config specified in bot.py!") + await client.change_presence(activity=discord.Game("config load error!")) + return + elif not generate_json(json_data=CONFIG, path=CONFIG_JSON, overwrite=True): + logger.critical("Failed to write config file on startup!") + await client.change_presence(activity=discord.Game("config load error!")) + return + + TSCLIENT_CONFIG = CONFIG['tsclient'] reload_client() if TSCLIENT is None: - print("Failed to create transmissionrpc client") + logger.critical("Failed to create transmissionrpc client") + await client.change_presence(activity=discord.Game("client load error!")) else: # client.loop.create_task(status_task()) - await client.change_presence(activity=discord.Game("{}help".format(BOT_PREFIX))) + await client.change_presence(activity=discord.Game("{}help".format(CONFIG['bot_prefix']))) print('Logged in as ' + client.user.name) print("Discord.py API version:", discord.__version__) print("Python version:", platform.python_version()) print("Running on:", platform.system(), platform.release(), "(" + os.name + ")") print('-------------------') + + # ch = client.get_channel(CONFIG['notification_channel_id']) + # await ch.send("test message") + # user = client.get_user(CONFIG['owner_user_ids'][0]) + # await user.send("test message") + if CONFIG['notification_enabled']: + task = asyncio.create_task(loop_notifications()) def humanseconds(S): if S == -2: @@ -551,9 +1031,14 @@ def tobytes(B): if numstr[1] in prefix[0]: return float(float(numstr[0]) * prefix[1]) -async def IsCompactOutput(context): +async def IsCompactOutput(message): if OUTPUT_MODE == OutputMode.AUTO: - if context.message.author.is_on_mobile(): + if message.author.dm_channel is not None and message.channel.id == message.author.dm_channel.id: + return OutputMode.DESKTOP + #user = client.get_user(message.author.id) + else: + user = message.author + if user.is_on_mobile(): return OutputMode.MOBILE else: return OutputMode.DESKTOP @@ -561,62 +1046,98 @@ async def IsCompactOutput(context): return OUTPUT_MODE # check that message author is allowed and message was sent in allowed channel -async def CommandPrecheck(context): +async def CommandPrecheck(message, whitelist=CONFIG['whitelist_user_ids']): # first set output mode global COMPACT_OUTPUT - COMPACT_OUTPUT = await IsCompactOutput(context) == OutputMode.MOBILE - - if len(CHANNEL_IDS) > 0 and context.message.channel.id not in CHANNEL_IDS: - await context.message.channel.send("I don't respond to commands in this channel...") + COMPACT_OUTPUT = await IsCompactOutput(message) == OutputMode.MOBILE + if (message.author.dm_channel is None or message.channel.id != message.author.dm_channel.id) and not CONFIG['listen_all_channels'] and message.channel.id not in CONFIG['listen_channel_ids']: + await message.channel.send("I don't respond to commands in this channel...") + await asyncio.sleep(2) + await message.delete() return False - elif context.message.author.id in BLACKLIST or (len(WHITELIST) > 0 and context.message.author.id not in WHITELIST): - await context.message.channel.send("You're not allowed to use this...") + if message.author.dm_channel is not None and message.channel.id == message.author.dm_channel.id and not CONFIG['listen_DMs']: + await message.channel.send("I don't respond to DMs...") + await asyncio.sleep(2) + await message.delete() + return False + if message.author.id in CONFIG['blacklist_user_ids'] or (len(whitelist) > 0 and message.author.id not in whitelist): + await message.channel.send("You're not allowed to use this...") + await asyncio.sleep(2) + await message.delete() return False return True + +async def message_clear_reactions(message, parent_message, reactions=[]): + if parent_message.author.dm_channel is None or parent_message.channel.id != parent_message.author.dm_channel.id: + if reactions == []: + await message.clear_reactions() + else: + for s in reactions: + await message.clear_reaction(s) - -@client.command(name='add', aliases=['a'], pass_context=True) -async def add(context, *, content = ""): - if await CommandPrecheck(context): +def message_has_torrent_file(message): + for f in message.attachments: + if len(f.filename) > 8 and f.filename[-8:].lower() == ".torrent": + return True + return False + +async def add(message, content = ""): + if await CommandPrecheck(message): torFileList = [] - for f in context.message.attachments: + for f in message.attachments: if len(f.filename) > 8 and f.filename[-8:].lower() == ".torrent": encodedBytes = base64.b64encode(await f.read()) encodedStr = str(encodedBytes, "utf-8") torFileList.append({"name":f.filename,"content":encodedStr}) continue if content == "" and len(torFileList) == 0: - await context.message.channel.send("🚫 Invalid string") + await message.channel.send("🚫 Invalid string") try: - await context.message.delete() + await message.delete() except: pass torStr = [] for t in torFileList: - # await context.message.channel.send('Adding torrent from file: {}\n Please wait...'.format(t["name"])) + # await message.channel.send('Adding torrent from file: {}\n Please wait...'.format(t["name"])) try: tor = add_torrent(t["content"]) + if tor: + logger.info("User {} ({}) added torrent from file: {} ({})".format(message.author.name, message.author.id, tor.name, tor.hashString)) + lock() + TORRENT_ADDED_USERS[tor.hashString] = message.author.id + unlock() + logger.debug("Added to TORRENT_ADDED_USERS") except: - await context.message.channel.send('‼️ Error communicating with Transmission ‼️') + await message.channel.send('‼️ Error communicating with Transmission ‼️') return torStr.append("From file: {}".format(tor.name)) for t in content.strip().split(" "): if len(t) > 5: - # await context.message.channel.send('Adding torrent from link: {}\n Please wait...'.format(t)) + # await message.channel.send('Adding torrent from link: {}\n Please wait...'.format(t)) try: tor = add_torrent(t) + if tor: + logger.info("User {} ({}) added torrent from URL: {} ({})".format(message.author.name, message.author.id, tor.name, tor.hashString)) + lock() + TORRENT_ADDED_USERS[tor.hashString] = message.author.id + unlock() + logger.debug("Added to TORRENT_ADDED_USERS") except: - await context.message.channel.send('‼️ Error communicating with Transmission ‼️') + await message.channel.send('‼️ Error communicating with Transmission ‼️') return torStr.append("From link: {}".format(tor.name)) if len(torStr) > 0: - await context.message.channel.send('✅ Added torrent{}:\n{}'.format("s" if len(torStr) > 1 else "", '\n'.join(torStr))) + await message.channel.send('🟢 Added torrent{}:\n{}'.format("s" if len(torStr) > 1 else "", '\n'.join(torStr))) else: - await context.message.channel.send('🚫 No torrents added!') + await message.channel.send('🚫 No torrents added!') + +@client.command(name='add', aliases=['a'], pass_context=True) +async def add_cmd(context, *, content = ""): + await add(context.message, content=content) # def torInfo(t): # states = ('downloading', 'seeding', 'stopped', 'finished','all') @@ -642,7 +1163,7 @@ torStates = ('downloading', 'seeding', 'stopped', 'verifying', 'queued', 'finish 'private', 'public', #9-10 'error', 'err_none', 'err_tracker_warn', 'err_tracker_error', 'err_local', # 11- ) -torStateEmoji = ('🔻','🌱','⏸','🩺','🚧','🏁', +torStateEmoji = ('🔻','🌱','⏸','🔬','🚧','🏁', '🐢','🐇','🚀', '🔐','🔓', '‼️','✅','⚠️','🌐','🖥' @@ -681,7 +1202,7 @@ def numTorInState(torrents, state): else: return 0 -def torSummary(torrents, repeat_msg_key=None): +def torSummary(torrents, repeat_msg_key=None, show_repeat=True): numInState = [numTorInState(torrents,s) for s in torStates] numTot = len(torrents) @@ -703,14 +1224,14 @@ def torSummary(torrents, repeat_msg_key=None): totDownRatio = '{:.2f}'.format(sumDown / sumTot * 100.0) - numTopRatios = min([len(torrents),5]) + numTopRatios = min([len(torrents),CONFIG['summary_num_top_ratio']]) topRatios = "• Top {} ratio{}:".format(numTopRatios,"s" if numTopRatios != 1 else "") sortByRatio = sorted(torrents,key=lambda t:float(t.ratio),reverse=True) for i in range(numTopRatios): topRatios += "\n {:.1f} {:.35}{}".format(float(sortByRatio[i].ratio),sortByRatio[i].name,"..." if len(sortByRatio[i].name) > 35 else "") embed=discord.Embed(description="*React to see list of corresponding transfers*", color=0xb51a00) - embed.set_author(name="Torrent Summary 🌊", icon_url=LOGO_URL) + embed.set_author(name="Torrent Summary 🌊", icon_url=CONFIG['logo_url']) embed.add_field(name="⬇️ {}/s".format(totDownRate), value="⬆️ {}/s".format(totUpRate), inline=False) embed.add_field(name="⏬ {} of {}".format(totDown,totSize), value="⏫ {} ⚖️ {}".format(totUp,totRatio), inline=False) embed.add_field(name="↕️ {} transfer{}".format(numTot, 's' if numTot != 1 else ''), value=' '.join(['{} {}'.format(i,j) for i,j in zip(torStateEmoji[:6], numInState[:6])]), inline=False) @@ -722,48 +1243,55 @@ def torSummary(torrents, repeat_msg_key=None): embed.add_field(name="Tracker", value='\n'.join(['{} {}'.format(i,j) for i,j in zip(torStateEmoji[9:11], numInState[9:11])]), inline=not COMPACT_OUTPUT) freq = REPEAT_MSGS[repeat_msg_key]['freq'] if repeat_msg_key else None - embed.set_footer(text=topRatios+"\n📜 Symbol legend{}".format('\nUpdating every {} second{}—❎ to stop'.format(freq,'s' if freq != 1 else '') if repeat_msg_key else ', 🔄 to auto-update')) - # await context.message.channel.send(embed=embed) + if show_repeat: + embed.set_footer(text="{}📜 Symbol legend{}".format((topRatios + '\n') if numTopRatios > 0 else '', '\nUpdating every {} second{}—❎ to stop'.format(freq,'s' if freq != 1 else '') if repeat_msg_key else ', 🔄 to auto-update')) + else: + embed.set_footer(text="{}📜 Symbol legend".format((topRatios + '\n') if numTopRatios > 0 else '')) return embed,numInState - -@client.command(name='summary',aliases=['s'], pass_context=True) -async def summary(context, *, content="", repeat_msg_key=None): + + +async def summary(message, content="", repeat_msg_key=None): global REPEAT_MSGS - if await CommandPrecheck(context): + if await CommandPrecheck(message): if not repeat_msg_key: if len(REPEAT_MSGS) == 0: reload_client() try: - await context.message.delete() + await message.delete() except: pass stateEmojiFilterStartNum = 3 # the first emoji in stateEmoji that corresponds to a list filter ignoreEmoji = ('✅') - summaryData=torSummary(TSCLIENT.get_torrents(), repeat_msg_key=repeat_msg_key) + summaryData=torSummary(TSCLIENT.get_torrents(), repeat_msg_key=repeat_msg_key, show_repeat=message.author.dm_channel is None or message.channel.id != message.author.dm_channel.id) if repeat_msg_key: msg = REPEAT_MSGS[repeat_msg_key]['msgs'][0] - if REPEAT_MSGS[repeat_msg_key]['reprint'] or (REPEAT_MSGS[repeat_msg_key]['pin_to_bottom'] and context.message.channel.last_message_id != msg.id): + if REPEAT_MSGS[repeat_msg_key]['reprint'] or (REPEAT_MSGS[repeat_msg_key]['pin_to_bottom'] and message.channel.last_message_id != msg.id): await msg.delete() - msg = await context.message.channel.send(embed=summaryData[0]) + msg = await message.channel.send(embed=summaryData[0]) REPEAT_MSGS[repeat_msg_key]['msgs'] = [msg] REPEAT_MSGS[repeat_msg_key]['reprint'] = False else: await msg.edit(embed=summaryData[0]) - if context.message.channel.last_message_id != msg.id: + if message.channel.last_message_id != msg.id and (message.author.dm_channel is None or message.channel.id != message.author.dm_channel.id): stateEmoji = ('📜','🖨','❎','↕️') + torStateEmoji stateEmojiFilterStartNum += 1 else: - stateEmoji = ('📜','❎','↕️') + torStateEmoji + stateEmoji = ('📜','↕️') + torStateEmoji + stateEmojiFilterStartNum -= 1 else: - stateEmoji = ('📜','🔄','↕️') + torStateEmoji - msg = await context.message.channel.send(embed=summaryData[0]) + if message.author.dm_channel is not None and message.channel.id == message.author.dm_channel.id: + stateEmoji = ('📜','↕️') + torStateEmoji + stateEmojiFilterStartNum -= 1 + else: + stateEmoji = ('📜','🔄','↕️') + torStateEmoji + msg = await message.channel.send(embed=summaryData[0]) # to get actual list of reactions, need to re-fetch the message from the server - cache_msg = await context.message.channel.fetch_message(msg.id) + cache_msg = await message.channel.fetch_message(msg.id) msgRxns = [str(r.emoji) for r in cache_msg.reactions] for i in stateEmoji[:stateEmojiFilterStartNum]: @@ -773,113 +1301,119 @@ async def summary(context, *, content="", repeat_msg_key=None): if summaryData[1][i] > 0 and stateEmoji[i+stateEmojiFilterStartNum] not in ignoreEmoji and stateEmoji[i+stateEmojiFilterStartNum] not in msgRxns: await msg.add_reaction(stateEmoji[i+stateEmojiFilterStartNum]) elif summaryData[1][i] == 0 and stateEmoji[i+stateEmojiFilterStartNum] in msgRxns: - await msg.clear_reaction(stateEmoji[i+stateEmojiFilterStartNum]) + await message_clear_reactions(msg, message, reactions=[stateEmoji[i+stateEmojiFilterStartNum]]) # if not repeat_msg_key: - # cache_msg = await context.message.channel.fetch_message(msg.id) + # cache_msg = await message.channel.fetch_message(msg.id) # for r in cache_msg.reactions: # if r.count > 1: # async for user in r.users(): - # if user.id in WHITELIST: + # if user.id in CONFIG['whitelist_user_ids']: # if str(r.emoji) == '📜': - # await msg.clear_reactions() + # await message_clear_reactions(msg, message) # await legend(context) # return # elif str(r.emoji) == '🔄': - # await msg.clear_reaction('🔄') - # await repeat_command(summary, context=context, content=content, msg_list=[msg]) + # await message_clear_reactions(msg, message, reactions=['🔄']) + # await repeat_command(summary, message=message, content=content, msg_list=[msg]) # return - # elif str(r.emoji) in stateEmoji[stateEmojiFilterStartNum-1:] and user.id == context.message.author.id: - # await msg.clear_reactions() - # await list_transfers(context, content=torStateFilters[str(r.emoji)]) + # elif str(r.emoji) in stateEmoji[stateEmojiFilterStartNum-1:] and user.id == message.author.id: + # await message_clear_reactions(msg, message) + # await list_transfers(message, content=torStateFilters[str(r.emoji)]) # return - cache_msg = await context.message.channel.fetch_message(msg.id) + cache_msg = await message.channel.fetch_message(msg.id) for r in cache_msg.reactions: if r.count > 1: async for user in r.users(): - if user.id in WHITELIST: + if user.id in CONFIG['whitelist_user_ids']: if str(r.emoji) == '📜': if repeat_msg_key: - await msg.clear_reaction('📜') + await message_clear_reactions(msg, message, reactions=['📜']) else: - await msg.clear_reactions() - await legend(context) + await message_clear_reactions(msg, message) + await legend(message) return elif str(r.emoji) == '❎': - await msg.clear_reactions() + await message_clear_reactions(msg, message) REPEAT_MSGS[repeat_msg_key]['do_repeat'] = False return elif str(r.emoji) == '🔄': - await msg.clear_reaction('🔄') - await repeat_command(summary, context=context, content=content, msg_list=[msg]) + await message_clear_reactions(msg, message, reactions=['🔄']) + await repeat_command(summary, message=message, content=content, msg_list=[msg]) return - elif str(r.emoji) in stateEmoji[stateEmojiFilterStartNum-1:] and user.id == context.message.author.id: + elif str(r.emoji) in stateEmoji[stateEmojiFilterStartNum-1:] and user.id == message.author.id: if repeat_msg_key: - await msg.clear_reaction(str(r.emoji)) + await message_clear_reactions(msg, message, reactions=[str(r.emoji)]) + asyncio.create_task(list_transfers(message, content=torStateFilters[str(r.emoji)])) else: - await msg.clear_reactions() - await list_transfers(context, content=torStateFilters[str(r.emoji)]) + await message_clear_reactions(msg, message) + await list_transfers(message, content=torStateFilters[str(r.emoji)]) return def check(reaction, user): - return user == context.message.author and reaction.message.id == msg.id and str(reaction.emoji) in stateEmoji + return user == message.author and reaction.message.id == msg.id and str(reaction.emoji) in stateEmoji try: reaction, user = await client.wait_for('reaction_add', timeout=60.0 if not repeat_msg_key else REPEAT_MSGS[repeat_msg_key]['freq'], check=check) except asyncio.TimeoutError: if not repeat_msg_key: - await msg.clear_reactions() + await message_clear_reactions(msg, message) return pass else: if str(reaction.emoji) in stateEmoji[stateEmojiFilterStartNum-1:] and str(reaction.emoji) not in ignoreEmoji: if repeat_msg_key: - await msg.clear_reaction(str(reaction.emoji)) + await message_clear_reactions(msg, message, reactions=[str(reaction.emoji)]) + asyncio.create_task(list_transfers(message, content=torStateFilters[str(reaction.emoji)])) else: - await msg.clear_reactions() - await list_transfers(context, content=torStateFilters[str(reaction.emoji)]) + await message_clear_reactions(msg, message) + await list_transfers(message, content=torStateFilters[str(reaction.emoji)]) return elif str(reaction.emoji) == '📜': if repeat_msg_key: - await msg.clear_reaction('📜') + await message_clear_reactions(msg, message, reactions=['📜']) else: - await msg.clear_reactions() - await legend(context) + await message_clear_reactions(msg, message) + await legend(message) return elif str(reaction.emoji) == '❎': - await msg.clear_reactions() + await message_clear_reactions(msg, message) REPEAT_MSGS[repeat_msg_key]['do_repeat'] = False return elif str(reaction.emoji) == '🔄': - await msg.clear_reaction('🔄') - await repeat_command(summary, context=context, content=content, msg_list=[msg]) + await message_clear_reactions(msg, message, reactions=['🔄']) + await repeat_command(summary, message=message, content=content, msg_list=[msg]) return elif str(reaction.emoji) == '🖨': - await msg.clear_reaction('🖨') + await message_clear_reactions(msg, message, reactions=['🖨']) REPEAT_MSGS[repeat_msg_key]['reprint'] = True return if repeat_msg_key: # a final check to see if the user has cancelled the repeat by checking the count of the cancel reaction - cache_msg = await context.message.channel.fetch_message(msg.id) + cache_msg = await message.channel.fetch_message(msg.id) for r in cache_msg.reactions: if r.count > 1: async for user in r.users(): - if user.id in WHITELIST: + if user.id in CONFIG['whitelist_user_ids']: if str(reaction.emoji) == '📜': - await msg.clear_reaction('📜') - await legend(context) + await message_clear_reactions(msg, message, reactions=['📜']) + await legend(message) return elif str(r.emoji) == '❎': REPEAT_MSGS[repeat_msg_key]['do_repeat'] = False - await msg.clear_reactions() + await message_clear_reactions(msg, message) return elif str(r.emoji) == '🖨': - await msg.clear_reaction('🖨') + await message_clear_reactions(msg, message, reactions=['🖨']) REPEAT_MSGS[repeat_msg_key]['reprint'] = True return elif str(r.emoji) in stateEmoji[stateEmojiFilterStartNum-1:]: - await msg.clear_reaction(str(r.emoji)) - await list_transfers(context, content=torStateFilters[str(r.emoji)]) + await message_clear_reactions(msg, message, reactions=[str(r.emoji)]) + await list_transfers(message, content=torStateFilters[str(r.emoji)]) return + +@client.command(name='summary',aliases=['s'], pass_context=True) +async def summary_cmd(context, *, content="", repeat_msg_key=None): + await summary(context.message, content, repeat_msg_key=repeat_msg_key) def strListToList(strList): if not re.match('^[0-9\,\-]+$', strList): @@ -900,7 +1434,7 @@ def strListToList(strList): def torList(torrents, author_name="Torrent Transfers",title=None,description=None): states = ('downloading', 'seeding', 'stopped', 'finished','checking','check pending','download pending','upload pending') - stateEmoji = {i:j for i,j in zip(states,['🔻','🌱','⏸','🏁','🩺','🩺','🚧','🚧'])} + stateEmoji = {i:j for i,j in zip(states,['🔻','🌱','⏸','🏁','🔬','🔬','🚧','🚧'])} errorStrs = ['✅','⚠️','🌐','🖥'] def torListLine(t): @@ -922,6 +1456,8 @@ def torList(torrents, author_name="Torrent Transfers",title=None,description=Non out += "{}%{} {:.1f}".format(int(t.progress), down, t.uploadRatio) elif t.status == 'finished': out += "{} {:.1f}".format(down, t.uploadRatio) + elif t.status == "checking": + out += "{:.1f}%".format(t.recheckProgress*100.0) else: down = humanbytes(t.progress * 0.01 * t.totalSize) out = "{} {} {} {} ".format(stateEmoji[t.status],errorStrs[t.error],'🚀' if t.rateDownload + t.rateUpload > 0 else '🐢' if t.isStalled else '🐇', '🔐' if t.isPrivate else '🔓') @@ -933,6 +1469,8 @@ def torList(torrents, author_name="Torrent Transfers",title=None,description=Non out += "⏬ {}/{} ({:.1f}%) ⚖️ *{:.2f}*".format(down,humanbytes(t.totalSize),t.progress,t.uploadRatio) elif t.status == 'finished': out += "⏬ {} ⚖️ {:.2f}".format(humanbytes(t.totalSize),t.uploadRatio) + elif t.status == "checking": + out += "{:.2f}%".format(t.recheckProgress*100.0) if t.error != 0: out += "\n***Error:*** *{}*".format(t.errorString) @@ -963,7 +1501,7 @@ def torList(torrents, author_name="Torrent Transfers",title=None,description=Non else: embeds.append(discord.Embed(title=title, description="No matching transfers found!", color=0xb51a00)) - embeds[-1].set_author(name=author_name, icon_url=LOGO_URL) + embeds[-1].set_author(name=author_name, icon_url=CONFIG['logo_url']) embeds[-1].set_footer(text="📜 Symbol legend") return embeds @@ -1023,20 +1561,20 @@ def torGetListOpsFromStr(listOpStr): return filter_by, sort_by, filter_regex, num_results -async def repeat_command(command, context, content="", msg_list=[]): +async def repeat_command(command, message, content="", msg_list=[]): global REPEAT_MSGS msg_key = secrets.token_hex() REPEAT_MSGS[msg_key] = { 'msgs':msg_list, 'command':command, - 'context':context, + 'message':message, 'content':content, 'pin_to_bottom':False, 'reprint': False, - 'freq':REPEAT_FREQ, - 'timeout':REPEAT_TIMEOUT, - 'timeout_verbose':REPEAT_TIMEOUT_VERBOSE, - 'cancel_verbose':REPEAT_CANCEL_VERBOSE, + 'freq':CONFIG['repeat_freq'], + 'timeout':CONFIG['repeat_timeout'], + 'timeout_verbose':CONFIG['repeat_timeout_verbose'], + 'cancel_verbose':CONFIG['repeat_cancel_verbose'], 'start_time':datetime.datetime.now(), 'do_repeat':True } @@ -1047,25 +1585,24 @@ async def repeat_command(command, context, content="", msg_list=[]): delta = datetime.datetime.now() - msg['start_time'] if delta.seconds >= msg['timeout']: if msg['timeout_verbose']: - await context.message.channel.send("❎ Auto-update timed out...") + await message.channel.send("❎ Auto-update timed out...") break else: try: - await msg['command'](context=msg['context'], content=msg['content'], repeat_msg_key=msg_key) + await msg['command'](message=msg['message'], content=msg['content'], repeat_msg_key=msg_key) except: - await asyncio.sleep(REPEAT_FREQ) + await asyncio.sleep(CONFIG['repeat_freq']) else: if msg['cancel_verbose']: - await context.message.channel.send("❎ Auto-update canceled...") + await message.channel.send("❎ Auto-update canceled...") break del REPEAT_MSGS[msg_key] return -@client.command(name='list', aliases=['l'], pass_context=True) -async def list_transfers(context, *, content="", repeat_msg_key=None): +async def list_transfers(message, content="", repeat_msg_key=None): global REPEAT_MSGS - if await CommandPrecheck(context): + if await CommandPrecheck(message): id_list = strListToList(content) filter_by = None sort_by = None @@ -1074,20 +1611,20 @@ async def list_transfers(context, *, content="", repeat_msg_key=None): if not id_list: filter_by, sort_by, filter_regex, num_results = torGetListOpsFromStr(content) if filter_by is not None and filter_by == -1: - await context.message.channel.send("Invalid filter specified. Choose one of {}".format(str(filter_names_full))) + await message.channel.send("Invalid filter specified. Choose one of {}".format(str(filter_names_full))) return if sort_by is not None and sort_by == -1: - await context.message.channel.send("Invalid sort specified. Choose one of {}".format(str(sort_names))) + await message.channel.send("Invalid sort specified. Choose one of {}".format(str(sort_names))) return if num_results is not None and num_results <= 0: - await context.message.channel.send("Must specify integer greater than 0 for `-N`!") + await message.channel.send("Must specify integer greater than 0 for `-N`!") return if not repeat_msg_key: if len(REPEAT_MSGS) == 0: reload_client() try: - await context.message.delete() + await message.delete() except: pass @@ -1095,11 +1632,14 @@ async def list_transfers(context, *, content="", repeat_msg_key=None): embeds = torList(torrents, title="{} transfer{} matching '`{}`'".format(len(torrents),'' if len(torrents)==1 else 's',content)) - embeds[-1].set_footer(text="📜 Symbol legend{}".format('\nUpdating every {} second{}—❎ to stop'.format(REPEAT_MSGS[repeat_msg_key]['freq'],'s' if REPEAT_MSGS[repeat_msg_key]['freq'] != 1 else '') if repeat_msg_key else ', 🔄 to auto-update')) - + if message.author.dm_channel is not None and message.channel.id == message.author.dm_channel.id: + embeds[-1].set_footer(text="📜 Symbol legend") + else: + embeds[-1].set_footer(text="📜 Symbol legend{}".format('\nUpdating every {} second{}—❎ to stop'.format(REPEAT_MSGS[repeat_msg_key]['freq'],'s' if REPEAT_MSGS[repeat_msg_key]['freq'] != 1 else '') if repeat_msg_key else ', 🔄 to auto-update')) + if repeat_msg_key: msgs = REPEAT_MSGS[repeat_msg_key]['msgs'] - if REPEAT_MSGS[repeat_msg_key]['reprint'] or (REPEAT_MSGS[repeat_msg_key]['pin_to_bottom'] and context.message.channel.last_message_id != msgs[-1].id): + if REPEAT_MSGS[repeat_msg_key]['reprint'] or (REPEAT_MSGS[repeat_msg_key]['pin_to_bottom'] and message.channel.last_message_id != msgs[-1].id): for m in msgs: await m.delete() msgs = [] @@ -1107,92 +1647,116 @@ async def list_transfers(context, *, content="", repeat_msg_key=None): for i,e in enumerate(embeds): if i < len(msgs): await msgs[i].edit(embed=e) - cache_msg = await context.message.channel.fetch_message(msgs[i].id) + cache_msg = await message.channel.fetch_message(msgs[i].id) if i < len(embeds) - 1 and len(cache_msg.reactions) > 0: - await cache_msg.clear_reactions() + await message_clear_reactions(cache_msg, message) else: - msgs.append(await context.message.channel.send(embed=e)) + msgs.append(await message.channel.send(embed=e)) if len(msgs) > len(embeds): for i in range(len(msgs) - len(embeds)): await msgs[-1].delete() del msgs[-1] REPEAT_MSGS[repeat_msg_key]['msgs'] = msgs - if context.message.channel.last_message_id != msgs[-1].id: + if message.channel.last_message_id != msgs[-1].id: rxnEmoji = ['📜','🖨','❎'] else: rxnEmoji = ['📜','❎'] else: - msgs = [await context.message.channel.send(embed=e) for e in embeds] - rxnEmoji = ['📜','🔄'] + msgs = [await message.channel.send(embed=e) for e in embeds] + if message.author.dm_channel is not None and message.channel.id == message.author.dm_channel.id: + rxnEmoji = ['📜','🔔','🔕'] + else: + rxnEmoji = ['📜','🔄','🔔','🔕'] msg = msgs[-1] # to get actual list of reactions, need to re-fetch the message from the server - cache_msg = await context.message.channel.fetch_message(msg.id) + cache_msg = await message.channel.fetch_message(msg.id) msgRxns = [str(r.emoji) for r in cache_msg.reactions] for e in msgRxns: if e not in rxnEmoji: - await msg.clear_reaction(e) + await message_clear_reactions(msg, message, reactions=[e]) for e in rxnEmoji: if e not in msgRxns: await msg.add_reaction(e) def check(reaction, user): - return user.id in WHITELIST and reaction.message.id == msg.id and str(reaction.emoji) in rxnEmoji + return user.id in CONFIG['whitelist_user_ids'] and reaction.message.id == msg.id and str(reaction.emoji) in rxnEmoji try: reaction, user = await client.wait_for('reaction_add', timeout=60.0 if not repeat_msg_key else REPEAT_MSGS[repeat_msg_key]['freq'], check=check) except asyncio.TimeoutError: if not repeat_msg_key: - await msg.clear_reactions() + await message_clear_reactions(msg, message) return pass else: if str(reaction.emoji) == '📜': if repeat_msg_key: - await msg.clear_reaction('📜') + await message_clear_reactions(msg, message, reactions=['📜']) else: - await msg.clear_reactions() - await legend(context) + await message_clear_reactions(msg, message) + await legend(message) return elif str(reaction.emoji) == '🖨': - await msg.clear_reaction('🖨') + await message_clear_reactions(msg, message, reactions=['🖨']) REPEAT_MSGS[repeat_msg_key]['reprint'] = True return elif str(reaction.emoji) == '❎': - await msg.clear_reactions() + await message_clear_reactions(msg, message) REPEAT_MSGS[repeat_msg_key]['do_repeat'] = False return elif str(reaction.emoji) == '🔄': - await msg.clear_reaction('🔄') - await repeat_command(list_transfers, context=context, content=content, msg_list=msgs) + await message_clear_reactions(msg, message, reactions=['🔄']) + await repeat_command(list_transfers, message=message, content=content, msg_list=msgs) return + elif str(reaction.emoji) == '🔔': + if len(torrents) > 0: + for t in torrents: + if t.hashString in TORRENT_NOTIFIED_USERS: + TORRENT_NOTIFIED_USERS[t.hashString].append(message.author.id) + else: + TORRENT_NOTIFIED_USERS[t.hashString] = [message.author.id] + embed = discord.Embed(title="🔔 Notifications enabled for:", description=",\n".join(["{}{}".format("" if len(torrents) == 1 else "**{}.**".format(i+1),j) for i,j in enumerate([t.name for t in torrents])])) + await user.send(embed=embed) + elif str(reaction.emoji) == '🔕': + if len(torrents) > 0: + for t in torrents: + if t.hashString in TORRENT_OPTOUT_USERS: + TORRENT_OPTOUT_USERS[t.hashString].append(message.author.id) + else: + TORRENT_OPTOUT_USERS[t.hashString] = [message.author.id] + embed = discord.Embed(title="🔕 Notifications disabled for:", description=",\n".join(["{}{}".format("" if len(torrents) == 1 else "**{}.**".format(i+1),j) for i,j in enumerate([t.name for t in torrents])])) + await user.send(embed=embed) if repeat_msg_key: # a final check to see if the user has cancelled the repeat by checking the count of the cancel reaction - cache_msg = await context.message.channel.fetch_message(msg.id) + cache_msg = await message.channel.fetch_message(msg.id) for r in cache_msg.reactions: if r.count > 1: async for user in r.users(): - if user.id in WHITELIST: + if user.id in CONFIG['whitelist_user_ids']: if str(r.emoji) == '🖨': REPEAT_MSGS[repeat_msg_key]['reprint'] = True - await msg.clear_reaction('🖨') + await message_clear_reactions(msg, message, reactions=['🖨']) elif str(r.emoji) == '📜': - await msg.clear_reaction('📜') - await legend(context) + await message_clear_reactions(msg, message, reactions=['📜']) + await legend(message) return elif str(r.emoji) == '❎': - await msg.clear_reactions() + await message_clear_reactions(msg, message) REPEAT_MSGS[repeat_msg_key]['do_repeat'] = False return else: # not a repeat message, so no need to keep the reactions - await msg.clear_reactions() + await message_clear_reactions(msg, message) -@client.command(name='modify', aliases=['m'], pass_context=True) -async def modify(context, *, content=""): - if await CommandPrecheck(context): +@client.command(name='list', aliases=['l'], pass_context=True) +async def list_transfers_cmd(context, *, content="", repeat_msg_key=None): + await list_transfers(context.message, content=content, repeat_msg_key=repeat_msg_key) + +async def modify(message, content=""): + if await CommandPrecheck(message): allOnly = content.strip() == "" torrents = [] if not allOnly: @@ -1204,17 +1768,17 @@ async def modify(context, *, content=""): if not id_list: filter_by, sort_by, filter_regex, num_results = torGetListOpsFromStr(content) if filter_by is not None and filter_by == -1: - await context.message.channel.send("Invalid filter specified. Choose one of {}".format(str(filter_names_full))) + await message.channel.send("Invalid filter specified. Choose one of {}".format(str(filter_names_full))) return if sort_by is not None and sort_by == -1: - await context.message.channel.send("Invalid sort specified. Choose one of {}".format(str(sort_names))) + await message.channel.send("Invalid sort specified. Choose one of {}".format(str(sort_names))) return if num_results is not None and num_results <= 0: - await context.message.channel.send("Must specify integer greater than 0 for `-N`!") + await message.channel.send("Must specify integer greater than 0 for `-N`!") return try: - await context.message.delete() + await message.delete() except: pass @@ -1225,16 +1789,16 @@ async def modify(context, *, content=""): if len(torrents) > 0: ops = ["pause","resume","remove","removedelete","verify"] opNames = ["pause","resume","remove","remove and delete","verify"] - opEmoji = ['⏸','▶️','❌','🗑','🩺'] - opStr = "⏸pause ▶️resume ❌remove 🗑remove  and  delete 🩺verify" + opEmoji = ['⏸','▶️','❌','🗑','🔬'] + opStr = "⏸pause ▶️resume ❌remove 🗑remove  and  delete 🔬verify" embeds = torList(torrents,author_name="Click a reaction to choose modification".format(len(torrents), '' if len(torrents)==1 else 's'),title="{} transfer{} matching '`{}`' will be modified".format(len(torrents), '' if len(torrents)==1 else 's', content)) else: embed=discord.Embed(title="Modify transfers",color=0xb51a00) - embed.set_author(name="No matching transfers found!", icon_url=LOGO_URL) + embed.set_author(name="No matching transfers found!", icon_url=CONFIG['logo_url']) embeds = [embed] else: try: - await context.message.delete() + await message.delete() except: pass ops = ["pauseall","resumeall"] @@ -1242,10 +1806,10 @@ async def modify(context, *, content=""): opEmoji = ['⏸','▶️'] opStr = "⏸ pause or ▶️ resume all" embed=discord.Embed(title="React to choose modification",color=0xb51a00) - embed.set_author(name="All transfers will be affected!", icon_url=LOGO_URL) + embed.set_author(name="All transfers will be affected!", icon_url=CONFIG['logo_url']) embed.set_footer(text=opStr) embeds = [embed] - msgs = [await context.message.channel.send(embed=e) for e in embeds] + msgs = [await message.channel.send(embed=e) for e in embeds] if not allOnly and len(torrents) == 0: return @@ -1257,13 +1821,14 @@ async def modify(context, *, content=""): for i in opEmoji: await msgs[-1].add_reaction(i) - cache_msg = await context.message.channel.fetch_message(msg.id) + cache_msg = await message.channel.fetch_message(msg.id) for reaction in cache_msg.reactions: if reaction.count > 1: async for user in reaction.users(): - if user.id == context.message.author.id: + if user.id == message.author.id: if str(reaction.emoji) == opEmoji[-1]: - await legend(context) + await message_clear_reactions(msg, message) + await legend(message) elif str(reaction.emoji) in opEmoji[:-1]: cmds = {i:j for i,j in zip(opEmoji,ops)} cmdNames = {i:j for i,j in zip(opEmoji,opNames)} @@ -1273,25 +1838,31 @@ async def modify(context, *, content=""): doContinue = True msg2 = None if "remove" in cmds[str(reaction.emoji)]: + footerPrepend = "" + if CONFIG['private_transfers_protected']: + removeTorrents = [t for t in torrents if not t.isPrivate] + if len(removeTorrents) != len(torrents): + torrents = removeTorrents + footerPrepend = "(I'm not allowed to remove private transfers, but I'll do the public ones)\n" embed=discord.Embed(title="Are you sure you wish to remove{} {} transfer{}?".format(' and DELETE' if 'delete' in cmds[str(reaction.emoji)] else '', len(torrents), '' if len(torrents)==1 else 's'),description="**This action is irreversible!**",color=0xb51a00) - embed.set_footer(text="react ✅ to continue or ❌ to cancel") - msg2 = await context.message.channel.send(embed=embed) + embed.set_footer(text=footerPrepend + "React ✅ to continue or ❌ to cancel") + msg2 = await message.channel.send(embed=embed) for i in ['✅','❌']: await msg2.add_reaction(i) def check1(reaction, user): - return user == context.message.author and reaction.message.id == msg2.id and str(reaction.emoji) in ['✅','❌'] + return user == message.author and reaction.message.id == msg2.id and str(reaction.emoji) in ['✅','❌'] try: reaction, user = await client.wait_for('reaction_add', timeout=60.0, check=check1) except asyncio.TimeoutError: - await msg.clear_reactions() - await msg2.clear_reactions() + await message_clear_reactions(msg, message) + await message_clear_reactions(msg2, message) doContinue = False else: doContinue = str(reaction.emoji) == '✅' if doContinue: - await context.message.channel.send("{} Trying to {} transfer{}, please wait...".format(str(reaction.emoji), cmdName, 's' if allOnly or len(torrents) > 1 else '')) + await message.channel.send("{} Trying to {} transfer{}, please wait...".format(str(reaction.emoji), cmdName, 's' if allOnly or len(torrents) > 1 else '')) if "pause" in cmd: stop_torrents(torrents) elif "resume" in cmd: @@ -1303,33 +1874,33 @@ async def modify(context, *, content=""): ops = ["pause","resume","remove","removedelete","pauseall","resumeall","verify"] opNames = ["paused","resumed","removed","removed and deleted","paused","resumed","queued for verification"] - opEmoji = ["⏸","▶️","❌","🗑","⏸","▶️","🩺"] + opEmoji = ["⏸","▶️","❌","🗑","⏸","▶️","🔬"] ops = {i:j for i,j in zip(ops,opNames)} opEmoji = {i:j for i,j in zip(ops,opEmoji)} - await context.message.channel.send("{} Transfer{} {}".format(str(reaction.emoji),'s' if allOnly or len(torrents) > 1 else '', ops[cmd])) - await msg.clear_reactions() + await message.channel.send("{} Transfer{} {}".format(str(reaction.emoji),'s' if allOnly or len(torrents) > 1 else '', ops[cmd])) + await message_clear_reactions(msg, message) if msg2 is not None: - await msg2.clear_reactions() + await message_clear_reactions(msg2, message) return else: - await context.message.channel.send("❌ Cancelled!") - await msg.clear_reactions() + await message.channel.send("❌ Cancelled!") + await message_clear_reactions(msg, message) if msg2 is not None: - await msg2.clear_reactions() + await message_clear_reactions(msg2, message) return def check(reaction, user): - return user == context.message.author and reaction.message.id == msg.id and str(reaction.emoji) in opEmoji + return user == message.author and reaction.message.id == msg.id and str(reaction.emoji) in opEmoji try: reaction, user = await client.wait_for('reaction_add', timeout=60.0, check=check) except asyncio.TimeoutError: - await msg.clear_reactions() + await message_clear_reactions(msg, message) return else: if str(reaction.emoji) == opEmoji[-1]: - await msg.clear_reactions() - await legend(context) + await message_clear_reactions(msg, message) + await legend(message) elif str(reaction.emoji) in opEmoji[:-1]: cmds = {i:j for i,j in zip(opEmoji,ops)} cmdNames = {i:j for i,j in zip(opEmoji,opNames)} @@ -1341,23 +1912,23 @@ async def modify(context, *, content=""): if "remove" in cmds[str(reaction.emoji)]: embed=discord.Embed(title="Are you sure you wish to remove{} {} transfer{}?".format(' and DELETE' if 'delete' in cmds[str(reaction.emoji)] else '', len(torrents), '' if len(torrents)==1 else 's'),description="**This action is irreversible!**",color=0xb51a00) embed.set_footer(text="react ✅ to continue or ❌ to cancel") - msg2 = await context.message.channel.send(embed=embed) + msg2 = await message.channel.send(embed=embed) for i in ['✅','❌']: await msg2.add_reaction(i) def check1(reaction, user): - return user == context.message.author and reaction.message.id == msg2.id and str(reaction.emoji) in ['✅','❌'] + return user == message.author and reaction.message.id == msg2.id and str(reaction.emoji) in ['✅','❌'] try: reaction, user = await client.wait_for('reaction_add', timeout=60.0, check=check1) except asyncio.TimeoutError: - await msg.clear_reactions() - await msg2.clear_reactions() + await message_clear_reactions(msg, message) + await message_clear_reactions(msg2, message) doContinue = False else: doContinue = str(reaction.emoji) == '✅' if doContinue: - await context.message.channel.send("{} Trying to {} transfer{}, please wait...".format(str(reaction.emoji), cmdName, 's' if allOnly or len(torrents) > 1 else '')) + await message.channel.send("{} Trying to {} transfer{}, please wait...".format(str(reaction.emoji), cmdName, 's' if allOnly or len(torrents) > 1 else '')) if "pause" in cmd: stop_torrents(torrents) elif "resume" in cmd: @@ -1371,38 +1942,45 @@ async def modify(context, *, content=""): ops = ["pause","resume","remove","removedelete","pauseall","resumeall","verify"] opNames = ["paused","resumed","removed","removed and deleted","paused","resumed","queued for verification"] - opEmoji = ["⏸","▶️","❌","🗑","⏸","▶️","🩺"] + opEmoji = ["⏸","▶️","❌","🗑","⏸","▶️","🔬"] ops = {i:j for i,j in zip(ops,opNames)} opEmoji = {i:j for i,j in zip(ops,opEmoji)} - await context.message.channel.send("{} Transfer{} {}".format(str(reaction.emoji),'s' if allOnly or len(torrents) > 1 else '', ops[cmd])) - await msg.clear_reactions() + await message.channel.send("{} Transfer{} {}".format(str(reaction.emoji),'s' if allOnly or len(torrents) > 1 else '', ops[cmd])) + await message_clear_reactions(msg, message) if msg2 is not None: - await msg2.clear_reactions() + await message_clear_reactions(msg2, message) return else: - await context.message.channel.send("❌ Cancelled!") - await msg.clear_reactions() + await message.channel.send("❌ Cancelled!") + await message_clear_reactions(msg, message) if msg2 is not None: - await msg2.clear_reactions() + await message_clear_reactions(msg2, message) return - await msg.clear_reactions() + await message_clear_reactions(msg, message) -@client.command(name='compact', aliases=['c'], pass_context=True) -async def toggle_compact_out(context): +@client.command(name='modify', aliases=['m'], pass_context=True) +async def modify_cmd(context, *, content=""): + await modify(context.message, content=content) + +async def toggle_compact_out(message): global OUTPUT_MODE if OUTPUT_MODE == OutputMode.AUTO: - if context.message.author.is_on_mobile(): + if message.author.is_on_mobile(): OUTPUT_MODE = OutputMode.DESKTOP - await context.message.channel.send('🖥 Switched to desktop output') + await message.channel.send('🖥 Switched to desktop output') else: OUTPUT_MODE = OutputMode.MOBILE - await context.message.channel.send('📱 Switched to mobile output') + await message.channel.send('📱 Switched to mobile output') else: OUTPUT_MODE = OutputMode.AUTO - await context.message.channel.send("🧠 Switched to smart selection of output (for you, {})".format('📱 mobile' if context.message.author.is_on_mobile() else '🖥 desktop')) + await message.channel.send("🧠 Switched to smart selection of output (for you, {})".format('📱 mobile' if message.author.is_on_mobile() else '🖥 desktop')) return +@client.command(name='compact', aliases=['c'], pass_context=True) +async def toggle_compact_out_cmd(context): + await toggle_compact_out(context.message) + async def LegendGetEmbed(embed_data=None): isCompact = False #COMPACT_OUTPUT joinChar = ',' if isCompact else '\n' @@ -1412,21 +1990,25 @@ async def LegendGetEmbed(embed_data=None): else: embed = discord.Embed(title='Symbol legend', color=0xb51a00) - embed.add_field(name="Status", value=joinChar.join(["🔻—downloading","🌱—seeding","⏸—paused","🩺—verifying","🚧—queued","🏁—finished","↕️—any"]), inline=not isCompact) - embed.add_field(name="Metrics", value=joinChar.join(["⬇️—download  rate","⬆️—upload  rate","⏬—total  downloaded","⏫—total  uploaded","⚖️—seed  ratio","⏳—ETA"]), inline=not isCompact) - embed.add_field(name="Modifications", value=joinChar.join(["⏸—pause","▶️—resume","❌—remove","🗑—remove  and  delete","🩺—verify"]), inline=not isCompact) - embed.add_field(name="Error", value=joinChar.join(["✅—none","⚠️—tracker  warning","🌐—tracker  error","🖥—local  error"]), inline=not isCompact) - embed.add_field(name="Activity", value=joinChar.join(["🐢—stalled","🐇—active","🚀—running (rate>0)"]), inline=not isCompact) - embed.add_field(name="Tracker", value=joinChar.join(["🔐—private","🔓—public"]), inline=not isCompact) - embed.add_field(name="Messages", value=joinChar.join(["🔄—auto-update message","❎—cancel auto-update","🖨—reprint at bottom"]), inline=not isCompact) + embed.add_field(name="Status 🔍", value=joinChar.join(["🔻—downloading","🌱—seeding","⏸—paused","🔬—verifying","🚧—queued","🏁—finished","↕️—any"]), inline=not isCompact) + embed.add_field(name="Metrics 📊", value=joinChar.join(["⬇️—download  rate","⬆️—upload  rate","⏬—total  downloaded","⏫—total  uploaded","⚖️—seed  ratio","⏳—ETA"]), inline=not isCompact) + embed.add_field(name="Modifications 🧰", value=joinChar.join(["⏸—pause","▶️—resume","❌—remove","🗑—remove  and  delete","🔬—verify"]), inline=not isCompact) + embed.add_field(name="Error ‼️", value=joinChar.join(["✅—none","⚠️—tracker  warning","🌐—tracker  error","🖥—local  error"]), inline=not isCompact) + embed.add_field(name="Activity 📈", value=joinChar.join(["🐢—stalled","🐇—active","🚀—running (rate>0)"]), inline=not isCompact) + embed.add_field(name="Tracker 📡", value=joinChar.join(["🔐—private","🔓—public"]), inline=not isCompact) + embed.add_field(name="Messages 💬", value=joinChar.join(["🔄—auto-update message","❎—cancel auto-update","🖨—reprint at bottom"]), inline=not isCompact) + embed.add_field(name="Notifications 📣", value=joinChar.join(["🔔—enable","🔕—disable"]), inline=not isCompact) return embed -@client.command(name='legend', pass_context=True) -async def legend(context): - if await CommandPrecheck(context): - await context.message.channel.send(embed=await LegendGetEmbed()) +async def legend(message): + if await CommandPrecheck(message): + await message.channel.send(embed=await LegendGetEmbed()) return +@client.command(name='legend', pass_context=True) +async def legend_cmd(context): + await legend(context.message) + # @client.command(name='test', pass_context=True) # async def test(context): # if context.message.author.is_on_mobile(): @@ -1435,55 +2017,114 @@ async def legend(context): # await context.channel.send('on desktop') # return -@client.command(name='purge', aliases=['p'], pass_context=True) -async def purge(context): +async def purge(message): def is_pinned(m): return m.pinned - deleted = await context.channel.purge(limit=100, check=not is_pinned) - await context.channel.send('Deleted {} message(s)'.format(len(deleted))) + deleted = await message.channel.purge(limit=100, check=not is_pinned) + await message.channel.send('Deleted {} message(s)'.format(len(deleted))) return + +@client.command(name='purge', aliases=['p'], pass_context=True) +async def purge_cmd(context): + await purge(context.message) + +async def toggle_notifications(message): + global CONFIG + if message.author.dm_channel is not None and message.channel.id == message.author.dm_channel.id and CommandPrecheck(message): + if message.author.id in CONFIG['notification_DM_opt_out_user_ids']: + CONFIG['notification_DM_opt_out_user_ids'].remove(message.author.id) + message.channel.send('🔕 DM notifications disabled') + else: + CONFIG['notification_DM_opt_out_user_ids'].append(message.author.id) + message.channel.send('🔔 DM notifications enabled') + generate_json(json_data=CONFIG, path=CONFIG_JSON, overwrite=True) + elif CommandPrecheck(message, whitelist=CONFIG['owner_user_ids']): + if CONFIG['notification_enabled_in_channel']: + CONFIG['notification_enabled_in_channel'] = False + message.channel.send('🔕 In-channel notifications disabled') + else: + CONFIG['notification_enabled_in_channel'] = True + message.channel.send('🔔 In-channel notifications enabled') + + return + +@client.command(name='notifications', aliases=['n'], pass_context=True) +async def toggle_notifications_cmd(context): + await toggle_notifications(context.message) + +@client.event +async def on_message(message): + if message.author.id == client.user.id: + return + if message_has_torrent_file(message): + await add(message, content=message.content) + if message.author.dm_channel is not None and message.channel.id == message.author.dm_channel.id: # dm only + if len(message.content) >= len("summary") and "summary" == message.content[:len("summary")]: + await summary(message) + elif len(message.content) >= len("list") and "list" in message.content[:len("list")]: + await list_transfers(message, content=message.content[len("list"):].strip()) + elif len(message.content) >= len("add") and "add" in message.content[:len("add")]: + await add(message, content=message.content[len("add"):].strip()) + elif len(message.content) >= len("modify") and "modify" in message.content[:len("modify")]: + await modify(message, content=message.content[len("modify"):].strip()) + elif len(message.content) >= len("legend") and "legend" in message.content[:len("legend")]: + await legend(message) + elif len(message.content) >= len("help") and "help" in message.content[:len("help")]: + await help(message, content=message.content[len("help"):].strip()) + elif len(message.content) >= len("notifications") and "notifications" in message.content[:len("notifications")]: + await toggle_notifications(message) + elif len(message.content) >= len("compact") and "compact" in message.content[:len("compact")]: + await toggle_compact_out(message) + else: + await client.process_commands(message) + elif not message.guild: # group dm only + # do stuff here # + pass + else: # server text channel + await client.process_commands(message) + client.remove_command('help') -@client.command(name='help', description='Help HUD.', brief='HELPOOOO!!!', pass_context=True) -async def help(context, *, content=""): - if await CommandPrecheck(context): +async def help(message, content=""): + if await CommandPrecheck(message): if content != "": if content in ["l","list"]: embed = discord.Embed(title='List transfers', color=0xb51a00) - embed.set_author(name="List current transfers with sorting, filtering, and search options", icon_url=LOGO_URL) - embed.add_field(name="Usage", value='`{0}list [--filter FILTER] [--sort SORT] [-N NUM_RESULTS] [NAME]`'.format(BOT_PREFIX), inline=False) + embed.set_author(name="List current transfers with sorting, filtering, and search options", icon_url=CONFIG['logo_url']) + embed.add_field(name="Usage", value='`{0}list [--filter FILTER] [--sort SORT] [-N NUM_RESULTS] [NAME]`'.format(CONFIG['bot_prefix']), inline=False) embed.add_field(name="Filtering", value='`--filter FILTER` or `-f FILTER`\n`FILTER` is one of `{}`'.format(str(filter_names_full)), inline=False) embed.add_field(name="Sorting", value='`--sort SORT` or `-s SORT`\n`SORT` is one of `{}`'.format(str(sort_names)), inline=False) embed.add_field(name="Specify number of results to show", value='`-N NUM_RESULTS`\n`NUM_RESULTS` is an integer greater than 0', inline=False) embed.add_field(name="Searching by name", value='`NAME` is a regular expression used to search transfer names (no enclosing quotes; may contain spaces)', inline=False) - embed.add_field(name="Examples", value="*List all transfers:* `{0}list`\n*Search using phrase 'ubuntu':* `{0}l ubuntu`\n*List downloading transfers:* `{0}l -f downloading`\n*List 10 most recently added transfers (sort transfers by age and specify number):* `{0}list --sort age -N 10`".format(BOT_PREFIX), inline=False) - await context.message.channel.send(embed=embed) + embed.add_field(name="Examples", value="*List all transfers:* `{0}list`\n*Search using phrase 'ubuntu':* `{0}l ubuntu`\n*List downloading transfers:* `{0}l -f downloading`\n*List 10 most recently added transfers (sort transfers by age and specify number):* `{0}list --sort age -N 10`".format(CONFIG['bot_prefix']), inline=False) + await message.channel.send(embed=embed) elif content in ["a","add"]: embed = discord.Embed(title='Add transfer', description="If multiple torrents are added, separate them by spaces", color=0xb51a00) - embed.set_author(name="Add one or more specified torrents by magnet link, url to torrent file, or by attaching a torrent file", icon_url=LOGO_URL) - embed.add_field(name="Usage", value='`{0}add TORRENT_FILE_URL_OR_MAGNET_LINK ...`\n`{0}a TORRENT_FILE_URL_OR_MAGNET_LINK ...`'.format(BOT_PREFIX), inline=False) - embed.add_field(name="Examples", value="*Add download of Linux Ubuntu using link to torrent file:* `{0}add https://releases.ubuntu.com/20.04/ubuntu-20.04.1-desktop-amd64.iso.torrent`\n*Add download of ubuntu using the actual `.torrent` file:* Select the `.torrent` file as an attachmend in Discord, then enter `t/a` as the caption".format(BOT_PREFIX), inline=False) - await context.message.channel.send(embed=embed) + embed.set_author(name="Add one or more specified torrents by magnet link, url to torrent file, or by attaching a torrent file", icon_url=CONFIG['logo_url']) + embed.add_field(name="Usage", value='`{0}add TORRENT_FILE_URL_OR_MAGNET_LINK ...`\n`{0}a TORRENT_FILE_URL_OR_MAGNET_LINK ...`'.format(CONFIG['bot_prefix']), inline=False) + embed.add_field(name="Examples", value="*Add download of Linux Ubuntu using link to torrent file:* `{0}add https://releases.ubuntu.com/20.04/ubuntu-20.04.1-desktop-amd64.iso.torrent`\n*Add download of ubuntu using the actual `.torrent` file:* Select the `.torrent` file as an attachmend in Discord, then enter `t/a` as the caption".format(CONFIG['bot_prefix']), inline=False) + await message.channel.send(embed=embed) elif content in ["m","modify"]: embed = discord.Embed(title='Modify existing transfer(s)', color=0xb51a00) - embed.set_author(name="Pause, resume, remove, or remove and delete specified transfer(s)", icon_url=LOGO_URL) - embed.add_field(name="Usage", value='`{0}modify [LIST_OPTIONS] [TORRENT_ID_SPECIFIER]`'.format(BOT_PREFIX), inline=False) - embed.add_field(name="Pause or resume ALL transfers", value="Simply run `{0}modify` to pause or resume all existing transfers".format(BOT_PREFIX), inline=False) - embed.add_field(name="By list options", value='`LIST_OPTIONS` is a valid set of options to the `{0}list` command (see `{0}help list` for details)'.format(BOT_PREFIX), inline=False) - embed.add_field(name="By ID specifier", value='`TORRENT_ID_SPECIFIER` is a valid transfer ID specifier—*e.g.* `1,3-5,9` to specify transfers 1, 3, 4, 5, and 9\n*Transfer IDs are the left-most number in the list of transfers (use* `{0}list` *to print full list)*'.format(BOT_PREFIX), inline=False) - embed.add_field(name="Examples", value="`{0}modify`\n`{0}m seinfeld`\n`{0}m 23,34,36-42`\n`{0}m --filter downloading seinfeld`".format(BOT_PREFIX), inline=False) - await context.message.channel.send(embed=embed) + embed.set_author(name="Pause, resume, remove, or remove and delete specified transfer(s)", icon_url=CONFIG['logo_url']) + embed.add_field(name="Usage", value='`{0}modify [LIST_OPTIONS] [TORRENT_ID_SPECIFIER]`'.format(CONFIG['bot_prefix']), inline=False) + embed.add_field(name="Pause or resume ALL transfers", value="Simply run `{0}modify` to pause or resume all existing transfers".format(CONFIG['bot_prefix']), inline=False) + embed.add_field(name="By list options", value='`LIST_OPTIONS` is a valid set of options to the `{0}list` command (see `{0}help list` for details)'.format(CONFIG['bot_prefix']), inline=False) + embed.add_field(name="By ID specifier", value='`TORRENT_ID_SPECIFIER` is a valid transfer ID specifier—*e.g.* `1,3-5,9` to specify transfers 1, 3, 4, 5, and 9\n*Transfer IDs are the left-most number in the list of transfers (use* `{0}list` *to print full list)*'.format(CONFIG['bot_prefix']), inline=False) + embed.add_field(name="Examples", value="`{0}modify`\n`{0}m seinfeld`\n`{0}m 23,34,36-42`\n`{0}m --filter downloading seinfeld`".format(CONFIG['bot_prefix']), inline=False) + await message.channel.send(embed=embed) else: embed = discord.Embed(title='List of commands:', color=0xb51a00) - embed.set_author(name='Transmission Bot: Manage torrent file transfers', icon_url=LOGO_URL) - embed.add_field(name="Print summary of transfers", value="*print summary from all transfers, with followup options to list transfers*\n*ex.* `{0}summary` or `{0}s`".format(BOT_PREFIX), inline=False) - embed.add_field(name="List torrent transfers", value="*list current transfers with sorting, filtering, and search options*\n*ex.* `{0}list [OPTIONS]` or `{0}l [OPTIONS]`".format(BOT_PREFIX), inline=False) - embed.add_field(name="Add new torrent transfers", value="*add one or more specified torrents by magnet link, url to torrent file, or by attaching a torrent file*\n*ex.* `{0}add TORRENT ...` or `{0}a TORRENT ...`".format(BOT_PREFIX), inline=False) - embed.add_field(name="Modify existing transfers", value="*pause, resume, remove, or remove and delete specified transfers*\n*ex.* `{0}modify [TORRENT]` or `{0}m [TORRENT]`".format(BOT_PREFIX), inline=False) - embed.add_field(name='Toggle output style', value='*toggle between desktop (default), mobile (narrow), or smart selection of output style*\n*ex.* `{0}compact` or {0}c'.format(BOT_PREFIX), inline=False) - embed.add_field(name='Show legend', value='*prints legend showing the meaning of symbols used in the output of other commands*\n*ex.* `{0}legend`'.format(BOT_PREFIX), inline=False) - embed.add_field(name='Help - Gives this menu', value='*with optional details of specified command*\n*ex.* `{0}help` or `{0}help COMMAND`'.format(BOT_PREFIX), inline=False) + embed.set_author(name='Transmission Bot: Manage torrent file transfers', icon_url=CONFIG['logo_url']) + embed.add_field(name="Print summary of transfers", value="*print summary from all transfers, with followup options to list transfers*\n*ex.* `{0}summary` or `{0}s`".format(CONFIG['bot_prefix']), inline=False) + embed.add_field(name="List torrent transfers", value="*list current transfers with sorting, filtering, and search options*\n*ex.* `{0}list [OPTIONS]` or `{0}l [OPTIONS]`".format(CONFIG['bot_prefix']), inline=False) + embed.add_field(name="Add new torrent transfers", value="*add one or more specified torrents by magnet link, url to torrent file, or by attaching a torrent file*\n*ex.* `{0}add TORRENT ...` or `{0}a TORRENT ...`".format(CONFIG['bot_prefix']), inline=False) + embed.add_field(name="Modify existing transfers", value="*pause, resume, remove, or remove and delete specified transfers*\n*ex.* `{0}modify [TORRENT]` or `{0}m [TORRENT]`".format(CONFIG['bot_prefix']), inline=False) + embed.add_field(name='Toggle output style', value='*toggle between desktop (default), mobile (narrow), or smart selection of output style*\n*ex.* `{0}compact` or {0}c'.format(CONFIG['bot_prefix']), inline=False) + embed.add_field(name='Toggle notifications', value='*toggle notifications regarding transfer state changes to be checked every {1} seconds (can be changed in config file)*\n*ex.* `{0}notifications` or {0}n'.format(CONFIG['bot_prefix'], CONFIG['notification_freq']), inline=False) + embed.add_field(name='Show legend', value='*prints legend showing the meaning of symbols used in the output of other commands*\n*ex.* `{0}legend`'.format(CONFIG['bot_prefix']), inline=False) + embed.add_field(name='Help - Gives this menu', value='*with optional details of specified command*\n*ex.* `{0}help` or `{0}help COMMAND`'.format(CONFIG['bot_prefix']), inline=False) # if not COMPACT_OUTPUT: # legendEmbed=await LegendGetEmbed() @@ -1491,7 +2132,11 @@ async def help(context, *, content=""): # for f in legendEmbed.fields: # embed.add_field(name=f.name, value=f.value, inline=f.inline) - await context.message.channel.send(embed=embed) + await message.channel.send(embed=embed) + +@client.command(name='help', description='Help HUD.', brief='HELPOOOO!!!', pass_context=True) +async def help_cmd(context, *, content=""): + await help(context.message, content) @client.event async def on_command_error(context, error): @@ -1559,7 +2204,7 @@ async def on_command_error(context, error): embed = discord.Embed(title="Error!", description="I don't know that command!", color=0xb51a00) message = await context.message.channel.send(embed=embed) await asyncio.sleep(2) - await help(context) + await help_cmd(context) raise error -client.run(TOKEN) +client.run(CONFIG['bot_token'])