Initial commit, basic functionality

This commit is contained in:
a 2024-07-28 01:26:36 -04:00
commit b0928dfae2
5 changed files with 298 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
config.conf
*~
#*

268
bot.py Normal file
View File

@ -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()

8
config.conf.template Normal file
View File

@ -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

10
docker-compose.yml Normal file
View File

@ -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

9
init.sh Executable file
View File

@ -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