commit b0928dfae253630923af961b36e395d2864c92cc Author: a Date: Sun Jul 28 01:26:36 2024 -0400 Initial commit, basic functionality diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9069db8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +config.conf +*~ +#* \ No newline at end of file diff --git a/bot.py b/bot.py new file mode 100644 index 0000000..ae306e0 --- /dev/null +++ b/bot.py @@ -0,0 +1,268 @@ +import asyncio +import logging +import slixmpp +import requests +import json +import datetime +import time +import configparser +import os + +def json_to_key_value_string(json_data): + # Parse JSON data + data = json.loads(json_data) + + # Initialize an empty list to store key-value pairs + result_lines = [] + + # Recursively process the JSON data + def process_item(item, prefix=''): + if isinstance(item, dict): + for key, value in item.items(): + if isinstance(value, (dict, list)): + process_item(value, f"{prefix}{key}.") + else: + result_lines.append(f"{prefix}{key}: {value}") + elif isinstance(item, list): + for i, value in enumerate(item): + process_item(value, f"{prefix}[{i}].") + + process_item(data) + + return "\n".join(result_lines) + + +class QBittorrentAPICaller(): + def __init__(self, url, username, password): + self.url = self.clean_url(url) + self.username = username + self.password = password + self.session = requests.Session() + self.login() + + def __del__(self): + self.logout() + + + def clean_url(self, url): + to_ret = url + # Prepend protocol (https by default) + if not to_ret.startswith(('http://', 'https://')): + to_ret = 'https://' + to_ret + + # Remove trailing / + if url.endswith('/'): + to_ret = to_ret[:-1] + + return to_ret + + def truncate_string(self, s, max_length=25): + return s[:max_length] + '...' if len(s) > max_length else s + + def login(self): + the_url = self.url + "/" + "api/v2/auth/login" + data={"username":self.username, "password":self.password} + + resp = self.session.post(the_url, data=data) + + def logout(self): + the_url = self.url + "/" + "api/v2/auth/logout" + resp = self.session.post(the_url) + + def version(self): + the_url = self.url + "/" + "api/v2/app/version" + resp = self.session.get(the_url) + if resp.status_code != 200: + return "Got status" + str(resp.status_code) + return resp.text + + def webapiVersion(self): + the_url = self.url + "/" + "api/v2/app/webapiVersion" + resp = self.session.get(the_url) + if resp.status_code != 200: + return "Got status" + str(resp.status_code) + return resp.text + + def buildInfo(self): + the_url = self.url + "/" + "api/v2/app/buildInfo" + resp = self.session.get(the_url) + if resp.status_code != 200: + return "Got status" + str(resp.status_code) + + return json_to_key_value_string(resp.text) + + def torrentList(self, modifier=""): + the_url = self.url + "/" + "api/v2/torrents/info" + to_ret = "" + for f in ["downloading", "completed"]: + resp = self.session.post(the_url, data={"filter":f}) + if resp.status_code != 200: + return "Got status" + str(resp.status_code) + + parsed = json.loads(resp.text) + to_ret += f.upper() + "\n" + if f == "downloading": + for p in parsed: + to_ret += (self.truncate_string(p["name"]) if modifier == "full" else p["name"]) + " | " + "{:.2f}".format(float(p["progress"])*100) + "% | " + "{:.2f}".format(float(p["dlspeed"])/1000000) + " MB/s" + " | " + str(datetime.timedelta(seconds=int(p["eta"]))) + "\n" + else: + for p in parsed: + to_ret += p["name"] + "\n" + + to_ret += "--\n" + + return to_ret + + def add(self, url, username): + if not url.startswith("magnet:"): + return "Please supply a magnet link (begins with \"magnet:\")" + + the_url = self.url + "/" + "api/v2/torrents/add" + + resp = self.session.post(the_url, data={"urls":url,"category":username}) + if (resp.status_code == 415): + return "Torrent file not valid" + + if (resp.status_code == 200): + # Get the hash + magnet_hash = url.split(":")[3].split("&")[0].lower() + # Make request to torrentlist with particular hash + the_url2 = self.url + "/" + "api/v2/torrents/info" + time.sleep(5) + resp2 = self.session.post(the_url2) + if (resp2.status_code != 200): + return "Could not verify if torrent was added" + parsed = json.loads(resp2.text) + for p in parsed: + if p["hash"] == magnet_hash: + if p["num_seeds"] == 0: + return "Successfully added " + p["name"] + "\nWarning: torrent has 0 seeds after 5 seconds" + else: + return "Successfully added " + p["name"] + + return "Could not add torrent, please double check the magnet link (hash=" + magnet_hash + ")" + + + + +class QBBot(slixmpp.ClientXMPP): + + def __init__(self, jid, password, room, nick, api_url, api_username, api_password): + slixmpp.ClientXMPP.__init__(self, jid, password) + + self.room = room + self.nick = nick + + self.api_caller = QBittorrentAPICaller(api_url, api_username, api_password) + + self.add_event_handler("session_start", self.session_start) + self.add_event_handler("message", self.message) + # The groupchat_message event is triggered whenever a message + # stanza is received from any chat room. If you also also + # register a handler for the 'message' event, MUC messages + # will be processed by both handlers. + self.add_event_handler("groupchat_message", self.muc_message) + + # If you wanted more functionality, here's how to register plugins: + # self.register_plugin('xep_0030') # Service Discovery + # self.register_plugin('xep_0199') # XMPP Ping + + # Here's how to access plugins once you've registered them: + # self['xep_0030'].add_feature('echo_demo') + + async def session_start(self, event): + await self.get_roster() + self.send_presence() + self.plugin['xep_0045'].join_muc(self.room, self.nick) + + # Most get_*/set_* methods from plugins use Iq stanzas, which + # are sent asynchronously. You can almost always provide a + # callback that will be executed when the reply is received. + + def message(self, msg): + if msg['type'] in ('chat', 'normal'): + msg.reply("I was not designed to take direct messages, please use me in a MUC").send() + + def muc_message(self, msg): + """ + Process incoming message stanzas from any chat room. Be aware + that if you also have any handlers for the 'message' event, + message stanzas may be processed by both handlers, so check + the 'type' attribute when using a 'message' event handler. + + Whenever the bot's nickname is mentioned, respond to + the message. + + IMPORTANT: Always check that a message is not from yourself, + otherwise you will create an infinite loop responding + to your own messages. + + This handler will reply to messages that mention + the bot's nickname. + + Arguments: + msg -- The received message stanza. See the documentation + for stanza objects and the Message stanza to see + how it may be used. + """ + #if msg['mucnick'] != self.nick and self.nick in msg['body']: + if msg['mucnick'] != self.nick and msg['body'].startswith(self.nick): + message = "" + tokens = msg['body'].split() + + match tokens[1]: + case "info": + #message += "URL: " + self.api_caller.url + "\n" + message += "QBittorrent Version: " + self.api_caller.version() + "\n" + message += "Web API Version: " + self.api_caller.webapiVersion() + "\n" + message += self.api_caller.buildInfo() + + # don't know why, but this behavior is reversed. Too lazy to figure it out + case "fulllist": + message += self.api_caller.torrentList("") + case "list": + message += self.api_caller.torrentList("full") + + case "add": + message += self.api_caller.add(tokens[2], msg['mucnick']) + + case "help"|_: + message += "Commands\n" + message += "info: Displays information about QBittorrent server" + "\n" + message += "help: Displays this help" + "\n" + message += "list: Lists torrents, downloading torrents' names truncated to 25 characters\n" + message += "fulllist: Lists torrents, no name truncation\n" + message += "add [MAGNET_URL]: Adds torrent corresponding to MAGNET_URL to the download list. Note that this will take about 5 seconds, as there's a check for 0 seeds after 5 seconds as a warning" + + self.send_message(mto=msg['from'].bare, + mbody=message, + mtype='groupchat') + + +if __name__ == '__main__': + # Ideally use optparse or argparse to get JID, + # password, and log level. + + logging.basicConfig(level=logging.ERROR, + format='%(levelname)-8s %(message)s') + + config = configparser.ConfigParser() + if not os.path.exists("./config.conf"): + logging.error("Could not find ./config.conf, please ensure it exists") + exit + + config.read('config.conf') + config_default = config['DEFAULT'] + xmpp = QBBot(config_default['qb_jid'].strip('\"'), + config_default['qb_pass'].strip('\"'), + config_default['qb_muc'].strip('\"'), + config_default['qb_nick'].strip('\"'), + config_default['api_url'].strip('\"'), + config_default['api_user'].strip('\"'), + config_default['api_pass'].strip('\"')) + + + xmpp.register_plugin('xep_0030') # Service Discovery + xmpp.register_plugin('xep_0045') # Multi-User Chat + xmpp.register_plugin('xep_0199') # XMPP Ping + xmpp.connect() + xmpp.process() diff --git a/config.conf.template b/config.conf.template new file mode 100644 index 0000000..25f01a2 --- /dev/null +++ b/config.conf.template @@ -0,0 +1,8 @@ +[DEFAULT] +qb_jid = # JID for the bot +qb_pass = # Password for the bot +qb_muc = # MUC the bot should connect to +qb_nick = # Nickname for the bot in the MUC +api_url = # URL for the qbittorrent api we are connecting to +api_user = # Username for the qbittorrent api +api_pass = # Password for the qbittorent api \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..72b8bbb --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,10 @@ +version: '3.8' # Specify the Docker Compose file format version + +services: + qb: + image: docker.io/alpine:latest # Use the latest Nginx image from Docker Hub + volumes: + - ./bot.py:/bot.py # Mount a local directory to the container + - ./init.sh:/init.sh + - ./config.conf:/config.conf + command: /init.sh diff --git a/init.sh b/init.sh new file mode 100755 index 0000000..2a5be0c --- /dev/null +++ b/init.sh @@ -0,0 +1,9 @@ +#!/bin/sh + +apk add python3 +apk add py3-pip +pip3 install --break-system-packages slixmpp +apk add py3-json +apk add py3-requests + +exec python3 /bot.py