From 45dedd576fb959b8bf94fa2a5c20a18e83ff5de3 Mon Sep 17 00:00:00 2001 From: itismadness Date: Sun, 11 Oct 2020 15:29:34 +0000 Subject: [PATCH] git dump --- .flake8 | 3 + Dockerfile | 15 ++ README.rst => README.md | 4 +- bin/hermes | 0 cache_view | 28 ++++ config.yml.sample | 24 ++-- hermes/api.py | 148 ++++++++++++++++++++ hermes/cache.py | 15 +- hermes/database.py | 10 +- hermes/hermes.py | 263 +++++++++++++++++++++++------------- hermes/irc.py | 75 ++++++++++ hermes/modules/apollo.py | 30 ---- hermes/modules/bot.py | 18 ++- hermes/modules/enter.py | 70 +++++++--- hermes/modules/fls.py | 22 ++- hermes/modules/interview.py | 26 +++- hermes/modules/orpheus.py | 112 +++++++++++++++ hermes/modules/quotes.py | 110 +++++++++++++++ hermes/modules/user.py | 3 +- hermes/modules/youtube.py | 14 +- hermes/utils.py | 19 +-- run_hermes | 0 22 files changed, 806 insertions(+), 203 deletions(-) create mode 100644 .flake8 create mode 100644 Dockerfile rename README.rst => README.md (96%) mode change 100755 => 100644 bin/hermes create mode 100644 cache_view create mode 100644 hermes/api.py create mode 100644 hermes/irc.py delete mode 100644 hermes/modules/apollo.py create mode 100644 hermes/modules/orpheus.py create mode 100644 hermes/modules/quotes.py mode change 100755 => 100644 run_hermes diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..9ebc5fd --- /dev/null +++ b/.flake8 @@ -0,0 +1,3 @@ +[flake8] +exclude = .git +max-line-length = 88 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fa75d3f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3-alpine + +#RUN apk --no +WORKDIR /srv/hermes + +COPY . /srv/hermes + +RUN pip3 install -r requirements.txt \ + && adduser -D -h /home/hermes -s /bin/sh hermes + +USER hermes + +RUN mkdir /home/hermes/.hermes + +CMD ["run_hermes", "-vv"] diff --git a/README.rst b/README.md similarity index 96% rename from README.rst rename to README.md index da9f692..f2bcd06 100644 --- a/README.rst +++ b/README.md @@ -10,7 +10,7 @@ The bot is written for Python 3. Installation ------------ -To install, run the setup.py file:: +To install, run the setup.py file python3 setup.py install @@ -21,7 +21,7 @@ setup to create new bin files) to run the bot through the "hermes" command. Usage ----- -Running hermes:: +Running hermes bin/hermes diff --git a/bin/hermes b/bin/hermes old mode 100755 new mode 100644 diff --git a/cache_view b/cache_view new file mode 100644 index 0000000..424cf86 --- /dev/null +++ b/cache_view @@ -0,0 +1,28 @@ +#! /usr/bin/env python3 + +import pprint +import sys +import os + +from hermes.api import GazelleAPI +from hermes.database import GazelleDB +from hermes.loader import load_modules +from hermes.utils import get_git_hash, check_pid, load_config, DotDict +from hermes.cache import Cache +from hermes.persist import PersistentStorage +from hermes.hermes import HERMES_DIR + +if __name__ == "__main__": + load_modules() + storage = PersistentStorage(os.path.join(HERMES_DIR, 'persist.dat')) + cache = Cache(storage['cache']) + + if len(sys.argv) > 1: + for k in sys.argv[1:]: + print('{0}: {1}'.format(k, pprint.pformat(cache[k]))) + else: + cache.expire() + keys = [k for k in iter(cache)] + for k in keys: + print('{0}: {1}'.format(k, pprint.pformat(cache[k]))) + diff --git a/config.yml.sample b/config.yml.sample index e3f83ff..640fcc2 100644 --- a/config.yml.sample +++ b/config.yml.sample @@ -1,35 +1,37 @@ nick: hermes name: hermes bot site: - tld: apollo.rip - url: https://apollo.rip + tld: orpheus.network + url: https://orpheus.network irc: host: 127.0.0.1 port: 6667 channels: - apollo: - name: apollo + orpheus: + name: orpheus + announce: + name: announce devs: name: devs min_level: 800 + staff: + name: staff + min_level: 800 oper: name: hermes password: password nickserv: password: password youtube_api: key -database: - host: 127.0.0.1 - port: 33060 - dbname: gazelle - username: gazelle - password: password +api: + id: id + key: password interview: class_id: 30 min_level: 800 speedtest_urls: - "(?:https?:\\/\\/)?(?:www\\.|beta\\.|legacy\\.)?speedtest\\.net\\/(?:my-)?result(?:\\/[\\w]{2})*\\/(\\d+)(?:\\.png)?" - site: https://interview.apollo.rip + site: https://interview.orpheus.network channels: - interview - interview2 diff --git a/hermes/api.py b/hermes/api.py new file mode 100644 index 0000000..7af1a18 --- /dev/null +++ b/hermes/api.py @@ -0,0 +1,148 @@ +""" +API (not ajax.php) interface for Gazelle to get information from the database +without having to directly connect to it. This class and Database should be +interchangeable in the bot and have it function just fine. +""" +import requests +import json +from urllib.parse import urljoin + +from .utils import convert + + +class GazelleAPI(object): + def __init__(self, site_url, api_id, api_key, cache): + self.site_url = site_url + self.api_id = api_id + self.api_key = api_key + self.api_url = urljoin( + self.site_url, + 'api.php?aid={}&token={}'.format(api_id, api_key) + ) + self.cache = cache + + def get_user(self, user): + try: + if isinstance(user, int): + r = requests.get(self.api_url, { + "action": "user", + "user_id": user + }) + else: + r = requests.get(self.api_url, { + "action": "user", + "username": user + }) + + if r.status_code == requests.codes.ok: + response = r.json() + return convert(response['response']) if response['status'] == 200 else None + else: + return None + except OSError: + return None + except json.decoder.JSONDecodeError: + return None + + def get_topic(self, topic_id): + try: + r = requests.get(self.api_url, { + "action": "forum", + "topic_id": topic_id + }) + if r.status_code == requests.codes.ok: + response = r.json() + return convert(response['response']) if response['status'] == 200 else None + else: + return None + except OSError: + return None + + def get_wiki(self, wiki_id): + try: + r = requests.get(self.api_url, { + "action": "wiki", + "wiki_id": wiki_id + }) + if r.status_code == requests.codes.ok: + response = r.json() + return convert(response['response']) if response['status'] == 200 else None + else: + return None + except OSError: + return None + + def get_request(self, request_id): + try: + r = requests.get(self.api_url, { + "action": "request", + "request_id": request_id + }) + if r.status_code == requests.codes.ok: + response = r.json() + return convert(response['response']) if response['status'] == 200 else None + else: + return None + except OSError: + return None + + def get_torrent(self, torrent_id): + try: + r = requests.get(self.api_url, { + "action": "torrent", + "req": "torrent", + "torrent_id": torrent_id + }) + if r.status_code == requests.codes.ok: + response = r.json() + return convert(response['response']) if response['status'] == 200 else None + else: + return None + except OSError: + return None + + def get_torrent_group(self, group_id): + try: + r = requests.get(self.api_url, { + "action": "torrent", + "req": "group", + "group_id": group_id + }) + if r.status_code == requests.codes.ok: + response = r.json() + return convert(response['response']) if response['status'] == 200 else None + else: + return None + except OSError: + return None + + def get_artist(self, artist_id): + try: + r = requests.get(self.api_url, { + "action": "artist", + "artist_id": artist_id + }) + if r.status_code == requests.codes.ok: + response = r.json() + return convert(response['response']) if response['status'] == 200 else None + else: + return None + except OSError: + return None + + def get_collage(self, collage_id): + try: + r = requests.get(self.api_url, { + "action": "collage", + "collage_id": collage_id + }) + if r.status_code == requests.codes.ok: + response = r.json() + return convert(response['response']) if response['status'] == 200 else None + else: + return None + except OSError: + return None + + def disconnect(self): + pass diff --git a/hermes/cache.py b/hermes/cache.py index 584537d..7f08a6c 100644 --- a/hermes/cache.py +++ b/hermes/cache.py @@ -6,11 +6,13 @@ from datetime import datetime, timedelta from .utils import DotDict + class CacheObject(object): def __init__(self, value, expiry): self.value = value self.expiry = expiry + class Cache(object): def __init__(self, storage=None, expiry=None): if storage is not None: @@ -41,6 +43,15 @@ class Cache(object): def __contains__(self, key): return key in self.storage + def keys(self): + return self.storage.keys() + + def items(self): + return self.storage.items() + + def values(self): + return self.storage.values() + def store(self, key, value, expiry=None): if not expiry: expiry = self.expiry @@ -63,7 +74,7 @@ class Cache(object): self.storage.clear() def expire(self): - for key in self.storage: + keys = [k for k in iter(self.storage)] + for key in keys: if self.storage[key].expiry < datetime.now(): del self.storage[key] - diff --git a/hermes/database.py b/hermes/database.py index 6534cb1..fb6cda5 100644 --- a/hermes/database.py +++ b/hermes/database.py @@ -1,7 +1,7 @@ """ -Utility module that maps the various gazelle tables to SQLAlchemy classes so that we can -use them nicely within hermes and not have to do something dumb like escaping our inputs -for use within DB queries (like zookeeper). +Utility module that maps the various gazelle tables to SQLAlchemy classes so +that we can use them nicely within hermes and not have to do something dumb +like escaping our inputs for use within DB queries (like zookeeper). """ from sqlalchemy import create_engine, ForeignKey, Column @@ -32,7 +32,7 @@ class GazelleDB(object): """ Given a username, get the User that it matches, else return None - :param username: + :param username: :return: User that the username belongs to if one exists :rtype: User """ @@ -42,7 +42,7 @@ class GazelleDB(object): """ Given a topic id, get the Topic that it matches, else return None - :param topic_id: + :param topic_id: :return: ForumTopics that topic_id belongs to if one exists :rtype: ForumTopics """ diff --git a/hermes/hermes.py b/hermes/hermes.py index 5613e60..54b4a0f 100644 --- a/hermes/hermes.py +++ b/hermes/hermes.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- """ -Core Hermes module which contains all of the logic for the Bot and running the proper commands -based off what modules have been loaded and registered for the bot. The file also contains some -utility functions that are used within the bot, those these functions may be moved elsewhere -as appopriate. +Core Hermes module which contains all of the logic for the Bot and running the proper +commands based off what modules have been loaded and registered for the bot. The file +also contains some utility functions that are used within the bot, those these +functions may be moved elsewhere as appopriate. """ import argparse import locale @@ -17,11 +17,13 @@ import ssl import sys import threading import time +import irc -from irc.bot import SingleServerIRCBot from irc.connection import Factory +from .api import GazelleAPI from .database import GazelleDB +from .irc import IRCBot from .loader import load_modules from .utils import get_git_hash, check_pid, load_config, DotDict from .cache import Cache @@ -52,7 +54,7 @@ def set_verbosity(verbose=0, level=logging.INFO): # noinspection PyMethodMayBeStatic,PyUnusedLocal -class Hermes(SingleServerIRCBot): +class Hermes(IRCBot): def __init__(self): self.logger = LOGGER self.dir = HERMES_DIR @@ -87,13 +89,25 @@ class Hermes(SingleServerIRCBot): self.database = None if 'socket' in self.config: - self.listener = Listener(self.config['socket']['host'], self.config['socket']['port']) - - self.database = GazelleDB(self.config.database.host, - self.config.database.dbname, - self.config.database.username, - self.config.database.password) + self.listener = Listener( + self.config['socket']['host'], + self.config['socket']['port'] + ) + if 'database' in self.config: + self.database = GazelleDB( + self.config.database.host, + self.config.database.dbname, + self.config.database.username, + self.config.database.password + ) + elif 'api' in self.config: + self.database = GazelleAPI( + self.config.site.url, + self.config.api.id, + self.config.api.key, + self.cache + ) self.logger.info("-> Loaded DB") @@ -103,7 +117,7 @@ class Hermes(SingleServerIRCBot): if hasattr(mod, 'setup'): mod.setup(self) self.logger.info("Loaded module: {}".format(name)) - except: + except BaseException: self.logger.exception("Error Module: {}".format(name)) if 'ssl' in self.config.irc and self.config.irc.ssl is True: @@ -118,20 +132,34 @@ class Hermes(SingleServerIRCBot): setattr(self, attr, self._dispatch) self.logger.info("-> Loaded IRC") + def set_nick(self, connection): + connection.send_raw('NICK {}'.format(self.nick)) + connection.send_raw('SETIDENT {} {}'.format(self.nick, self.nick)) + connection.send_raw("SETHOST {}.{}".format(self.nick, self.config.site.tld)) + if hasattr(self.config.irc, "nickserv"): + self.logger.info("-> Identifying with NickServ") + connection.privmsg("NickServ", "IDENTIFY {}".format( + self.config.irc.nickserv.password) + ) + def on_nicknameinuse(self, connection, event): """ - Executed if someone else has already taken the bot's nickname and we cannot take it - back via NickServ. We consider this a fatal error as this shouldn't happen in normal - usage and would happen if we tried to run the bot twice (which is unnecessary). + Executed if someone else has already taken the bot's nickname and we cannot + take it back via NickServ. Kill the offending user, and take the nick + through blood. :raises: SystemError """ - raise SystemError("*** ERROR: Bot's nickname in use! ***") + self.logger.info("-> killing user named {}".format(self.nick)) + connection.kill(self.nick) + self.set_nick(connection) + # raise SystemError("*** ERROR: Bot's nickname in use! ***") def on_erroneusenickname(self, connection, event): """ - Executed if the nickname contains illegal characters (such as #) which IRC does not - support. This is considered a fatal error and should only happen on poor configuration. + Executed if the nickname contains illegal characters (such as #) which IRC does + not support. This is considered a fatal error and should only happen on poor + configuration. :raises: SystemError """ @@ -139,9 +167,9 @@ class Hermes(SingleServerIRCBot): def on_welcome(self, connection, event): """ - Executed when the bot connects to the server (and gets the "welcome message"). We use - this to do some initialization routines (like joining the necessary channels, etc.) that - the bot needs to operate + Executed when the bot connects to the server (and gets the "welcome message"). + We use this to do some initialization routines (like joining the necessary + channels, etc.) that the bot needs to operate :param connection: :param event: @@ -153,19 +181,15 @@ class Hermes(SingleServerIRCBot): self.logger.info("-> Setting OPER") connection.send_raw("OPER {} {}".format(self.config.irc.oper.name, self.config.irc.oper.password)) - connection.send_raw('NICK {}'.format(self.nick)) - connection.send_raw('SETIDENT {} {}'.format(self.nick, self.nick)) - if hasattr(self.config.irc, "nickserv"): - self.logger.info("-> Identifying with NickServ") - connection.privmsg("NickServ", "IDENTIFY {}".format(self.config.irc.nickserv.password)) - connection.send_raw("SETHOST {}.{}".format(self.nick, self.config.site.tld)) - if self.listener is not None and self.listener.is_alive() == False: + self.set_nick(connection) + + if self.listener is not None and not self.listener.is_alive(): self.listener.set_connection(connection) self.listener.start() - if hasattr(self.config.irc, "channels") and isinstance(self.config.irc.channels, dict): + if hasattr(self.config.irc, "channels") and \ + isinstance(self.config.irc.channels, dict): for name in self.config.irc.channels: - channel = self.config.irc.channels[name] self.logger.info("-> Entering {}".format(name)) connection.send_raw("SAJOIN {} #{}".format(self.nick, name)) @@ -186,9 +210,10 @@ class Hermes(SingleServerIRCBot): func(self, connection, event, match) def check_admin(self, event): - return event.source.nick in self.config.admins and event.source.host is not None and \ - event.source.host.endswith(self.config.site.tld) and \ - event.source.host.split(",")[0] not in self.config.admins + return event.source.nick in self.config.admins \ + and event.source.host is not None \ + and event.source.host.endswith(self.config.site.tld) \ + and event.source.host.split(",")[0] not in self.config.admins def _dispatch(self, connection, event): """ @@ -196,11 +221,12 @@ class Hermes(SingleServerIRCBot): :param event: class that contains that describes the IRC event type (type of event, always privmsg) source (name of who sent the message containing host and nick) - nick - - user - - host - + nick - + user - + host - target (name of who is recieving the message, in this case the bot name) - arguments (list of arguments to the event, for this, [0] is message that was sent) + arguments (list of arguments to the event, for this, [0] is message that + was sent) tags (empty list) """ event.msg = event.arguments[0] @@ -219,13 +245,17 @@ class Hermes(SingleServerIRCBot): try: if event.type in func.events: self._execute_function(func, connection, event) - except: + except BaseException: if event.type == "privmsg": - msg = "I'm sorry, {}.{} threw an exception. Please tell an " \ - "administrator and try again later.".format(name, func.__name__) + msg = "I'm sorry, {}.{} threw an exception.".format( + name, + func.__name__ + ) + msg += " Please tell an administrator and try again later." connection.privmsg(event.source.nick, msg) - self.logger.exception("Failed to run function: {}.{}".format(name, - func.__name__)) + self.logger.exception( + "Failed to run function: {}.{}".format(name, func.__name__) + ) def disconnect(self, msg="I'll be back!"): if self.database is not None: @@ -261,27 +291,34 @@ class BotCheck(threading.Thread): self.alive = False self.join() + class SaveData(threading.Thread): def __init__(self, bot): super().__init__() self.alive = True self.bot = bot + self.logger = LOGGER def run(self): - time.sleep(120) + cycle = 480 while self.alive: - self.bot.storage.save() - time.sleep(600) + if cycle >= 600: + self.logger.info('saving data') + self.bot.storage.save() + cycle = 0 + cycle += 1 + time.sleep(1) def stop(self): self.alive = False self.join() + class Listener(threading.Thread): """ - Gazelle communicates with the IRC bot through a socket. Gazelle will send things like - new torrents (via announce) or reports/errors that the bot would then properly relay - into the appropriate IRC channels. + Gazelle communicates with the IRC bot through a socket. Gazelle will send things + like new torrents (via announce) or reports/errors that the bot would then properly + relay into the appropriate IRC channels. """ def __init__(self, host, port): self.logger = LOGGER @@ -306,26 +343,44 @@ class Listener(threading.Thread): server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) server_socket.bind((self.host, self.port)) server_socket.listen(5) - self.logger.info("-> Listener waiting for connection on port {}".format(self.port)) + self.logger.info( + "-> Listener waiting for connection on port {}".format(self.port) + ) while self.running: if self.restart: server_socket.send("RESTARTING") server_socket.close() - server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + server_socket = socket.socket( + socket.AF_INET, + socket.SOCK_STREAM + ) server_socket.bind((self.host, self.port)) server_socket.listen(5) client_socket, address = server_socket.accept() - data = client_socket.recv(512).decode('utf-8').strip() + # Only accept 510 bytes as irc module appends b'\r\n' to bring + # us to max of 512 + data = client_socket.recv(510).decode('utf-8', errors='replace').strip() self.logger.info("-> Listener Recieved: {}".format(data)) client_socket.close() try: data_details = data.split() - if data_details[0] in ["/privmsg", "privmsg"] and data_details[1] == "#": + if len(data_details) < 2: + continue + if data_details[0] in ["/privmsg", "privmsg"] \ + and data_details[1] == "#": continue if self.connection is not None: self.connection.send_raw(data) except socket.error as e: - self.logger.error("*** Socket Error: %d: %s ***" % (e.args[0], e.args[1])) + self.logger.error( + "*** Socket Error: %d: %s ***" % (e.args[0], e.args[1]) + ) + except irc.client.MessageTooLong: + self.logger.warn("-> Skipping input as too long: {}".format(data)) + except irc.client.InvalidCharacters: + self.logger.warn( + "-> Skipping message as contained newlines: {}".format(data) + ) server_socket.close() @@ -338,28 +393,44 @@ def get_version_string(): def _parse_args(): - parser = argparse.ArgumentParser(description="CLI for the hermes IRC bot for Gazelle") - parser.add_argument("-v", "--verbose", action='count', default=0, - help="Define how much logging to do. -v will log to file, -vv will also" - "log to sys.stdout") - parser.add_argument("--log-level", action='store', choices=['debug', 'info', 'warn', 'error'], - default='info', - help="What level of messages should be logged by hermes. Most " - "logged messages are either INFO or ERROR.") - parser.add_argument("-V", action='version', - version="%(prog)s ({})".format(get_version_string())) - parser.add_argument("--nofork", action="store_true", default=False, - help="Don't hermes as a forked daemon, as you'd like to interact with" - "the terminal it lives in in some way. This is automatically turned " - "on if you pass in the flag -vv.") - parser.add_argument("--no-eternal", action="store_true", default=False, - help="By default, the bot will attempt to restart itself after a crash" - "(assuming the last one was not 5 seconds ago), but use this flag" - "if you want the bot to die immediately.") - parser.add_argument("--stop", action='store_true', default=False, - help='Try and have a previous instance of Hermes gracefully stop') - parser.add_argument("--kill", action='store_true', default=False, - help="Try and have a previous intance of Hermes exit immediately.") + parser = argparse.ArgumentParser( + description="CLI for the hermes IRC bot for Gazelle" + ) + parser.add_argument( + "-v", "--verbose", + action='count', default=0, + help="Define how much logging to do. (-v to file, -vv to stdout)" + ) + parser.add_argument( + "--log-level", + action='store', choices=['debug', 'info', 'warn', 'error'], default='info', + help="What level of messages should be logged by hermes." + ) + parser.add_argument( + "-V", "--version", + action='version', + version="%(prog)s ({})".format(get_version_string()) + ) + parser.add_argument( + "--nofork", + action="store_true", default=False, + help="Don't run as forked daemon. (set if using -vv)" + ) + parser.add_argument( + "--no-eternal", + action="store_true", default=False, + help="No not attempt to restart bot in case of crash" + ) + parser.add_argument( + "--stop", + action='store_true', default=False, + help='Try and have a previous instance of Hermes gracefully stop' + ) + parser.add_argument( + "--kill", + action='store_true', default=False, + help="Try and have a previous intance of Hermes exit immediately." + ) return parser.parse_args() @@ -391,30 +462,36 @@ def run_hermes(): print("Killing instance of Hermes ({})".format(old_pid)) os.kill(old_pid, signal.SIGKILL) else: - raise SystemExit("{} already exists, exiting".format(pidfile)) - os.unlink(pidfile) - raise SystemExit() + raise SystemExit( + "{} already exists, exiting".format(pidfile) + ) + if os.path.isfile(pidfile): + os.unlink(pidfile) + raise SystemExit(0) elif args.stop or args.kill: raise SystemExit("Hermes is not currently running.") - # If we have set -vv, then we will not run as a daemon as we'll assume you wanted + # If we have set -vv, then we will not run as a daemon + # as we'll assume you wanted # to see the console output. if args.nofork is not True and args.verbose < 2: child_pid = os.fork() - if child_pid is not 0: + if child_pid != 0: raise SystemExit with open(pidfile, 'w') as open_pidfile: open_pidfile.write(str(os.getpid())) - last_run = None + irc.client.ServerConnection.buffer_class.errors = 'replace' + last_run = None + save_thread = None try: hermes = Hermes() save_thread = SaveData(hermes) save_thread.start() - #thread = BotCheck(hermes) - #thread.start() + # thread = BotCheck(hermes) + # thread.start() def signal_handler(sig, _): if sig is signal.SIGTERM: @@ -423,7 +500,7 @@ def run_hermes(): hermes.die() signal.signal(signal.SIGTERM, signal_handler) - #signal.signal(signal.SIGKILL, signal_handler) + # signal.signal(signal.SIGKILL, signal_handler) while run_eternal: # noinspection PyBroadException try: @@ -433,16 +510,15 @@ def run_hermes(): LOGGER.info("-> {}".format(e)) hermes.disconnect("Leaving...") LOGGER.info("Quitting bot") - #thread.stop() - raise SystemExit + break except RestartException: - #thread.stop() + # thread.stop() time.sleep(5) hermes = Hermes() - #thread = BotCheck(hermes) - #thread.start() + # thread = BotCheck(hermes) + # thread.start() hermes.start() - except: + except BaseException: if last_run > time.time() - 5: hermes.disconnect("Crashed, going offline.") run_eternal = False @@ -451,4 +527,7 @@ def run_hermes(): time.sleep(2) LOGGER.exception("Crash") finally: - os.unlink(pidfile) + if save_thread is not None: + save_thread.stop() + if os.path.isfile(pidfile): + os.unlink(pidfile) diff --git a/hermes/irc.py b/hermes/irc.py new file mode 100644 index 0000000..de8e408 --- /dev/null +++ b/hermes/irc.py @@ -0,0 +1,75 @@ +import irc.bot +import irc.client +from datetime import datetime, timedelta + + +class ServerConnection(irc.client.ServerConnection): + def __init__(self, reactor): + super(ServerConnection, self).__init__(reactor) + self.last_ping = None + self.last_pong = None + + def ping(self, target, target2=""): + """Send a PING command.""" + if not self.is_connected(): + return + self.last_ping = datetime.now() + self.send_items('PING', target, target2) + + def kill(self, nick, comment=""): + """Send a KILL command.""" + self.send_items('KILL', nick, comment and ':' + comment) + + +class Reactor(irc.client.Reactor): + connection_class = ServerConnection + + +class IRCBot(irc.bot.SingleServerIRCBot): + reactor_class = Reactor + timeout_interval = 40 + check_keepalive_interval = 20 + keepalive_interval = 10 + + def __init__( + self, + server_list, + nickname, + realname, + recon=None, + **connect_params + ): + if recon is None: + recon = irc.bot.ExponentialBackoff(min_interval=10, max_interval=300) + + super(IRCBot, self).__init__( + server_list, + nickname, + realname, + irc.bot.missing, + recon, + **connect_params + ) + + for i in ['welcome', 'pong']: + self.connection.add_global_handler(i, getattr(self, '_on_' + i), -20) + + self.reactor.scheduler.execute_every( + timedelta(seconds=self.check_keepalive_interval), + self.check_keepalive + ) + + def check_keepalive(self): + if self.connection.last_pong is None or not self.connection.is_connected(): + return + timeout = self.connection.last_pong + timedelta(seconds=self.timeout_interval) + if self.connection.last_ping > timeout: + self.connection.last_pong = None + self.disconnect('disconnecting...') + + def _on_welcome(self, connection, event): + period = timedelta(seconds=self.keepalive_interval) + connection.set_keepalive(period) + + def _on_pong(self, connection, event): + self.connection.last_pong = datetime.now() diff --git a/hermes/modules/apollo.py b/hermes/modules/apollo.py deleted file mode 100644 index 1ca3a21..0000000 --- a/hermes/modules/apollo.py +++ /dev/null @@ -1,30 +0,0 @@ -from re import IGNORECASE -from hermes.module import rule, event, disabled - - -@disabled() -@event("privmsg", "pubmsg") -@rule(r"(https|http):\/\/(apollo|xanax)\.rip\/forums\.php\?([a-zA-Z0-9=&]*)threadid=([0-9]+)", - IGNORECASE) -def parse_thread_url(bot, connection, event, match): - """ - - :param bot: - :type bot: hermes.Hermes - :param connection: - :param event: - :param match: - :return: - """ - topic = bot.database.get_topic(int(match.group(4))) - if event.type == "privmsg": - target = event.source.nick - else: - target = event.target - if topic is None: - msg = "Could not find topic" - else: - msg = "[ {} | " \ - "https://apollo.rip/forums.php?action=showthread&threadid={} ]".format(topic.title, - topic.id) - connection.privmsg(target, msg) diff --git a/hermes/modules/bot.py b/hermes/modules/bot.py index b2e1768..31a447b 100644 --- a/hermes/modules/bot.py +++ b/hermes/modules/bot.py @@ -54,10 +54,13 @@ def update_bot(bot, connection, event): if err is not None and str(err, "utf-8") != "": bot.logger.error(str(err, "utf-8")) os.chdir(current_dir) - get_version(bot,connection, event) - #restart_bot(bot, connection, event) + get_version(bot, connection, event) + # restart_bot(bot, connection, event) else: - connection.privmsg(event.source.nick, "Can only update bot if run from git directory.") + connection.privmsg( + event.source.nick, + "Can only update bot if run from git directory." + ) @admin_only() @@ -72,7 +75,10 @@ def kill_bot(bot, *_): @privmsg() @command("version") def get_version(_, connection, event): - connection.privmsg(event.source.nick, "Running version: {}".format(get_version_string())) + connection.privmsg( + event.source.nick, + "Running version: {}".format(get_version_string()) + ) @admin_only() @@ -82,7 +88,7 @@ def view_log(bot, connection, event): log_file = os.path.join(bot.dir, 'hermes.log') try: connection.privmsg(event.source.nick, "Log file: {}".format(log_file)) - for line in file_tail(log_file, 10): - connection.privmsg(event.source.nick, line) + for line in file_tail(log_file, 30): + connection.privmsg(event.source.nick, line.strip()) except Exception as e: connection.privmsg(event.source.nick, e) diff --git a/hermes/modules/enter.py b/hermes/modules/enter.py index c7988bd..549135c 100644 --- a/hermes/modules/enter.py +++ b/hermes/modules/enter.py @@ -8,13 +8,15 @@ Note, because the bot is using SAJOIN to add users to the channels that they req we cannot use a +b ban on a user to prevent them from joining a channel. Instead, we have to revoke their IRC privileges through Gazelle, then kick the user from the channels. """ -import re -from datetime import timedelta -from hermes.module import privmsg, command, help_message, example +from datetime import timedelta, datetime +from hermes.module import privmsg, command, help_message, example, admin_only + + +timeouts = {} def validate_irckey(user, irckey): - if user == None: + if user is None: return False, "No user found with that name." else: if user.Enabled != '1' or user.DisableIRC == '1': @@ -24,22 +26,24 @@ def validate_irckey(user, irckey): else: return False, "Invalid Username/IRC Key" + @privmsg() @command("enter") @help_message("Use this command to have the bot add you to any official channel") -@example("enter ", - "enter #APOLLO itismadness 123456", - "enter #APOLLO #announce itismadness 123456", - "enter #APOLLO,#announce itismadness 123456") +@example("enter ", + "enter #orpheus itismadness 123456", + "enter #orpheus #announce itismadness 123456", + "enter #orpheus,#announce itismadness 123456") def enter(bot, connection, event): """ - - :param bot: + + :param bot: :type bot: hermes.Hermes - :param connection: - :param event: - :return: + :param connection: + :param event: + :return: """ + global timeouts sent_nick = event.source.nick if len(event.args) < 3: @@ -48,25 +52,41 @@ def enter(bot, connection, event): return username = event.args[-2] password = event.args[-1] + + if username.lower() in timeouts: + if (datetime.now() - timeouts[username.lower()]) > timedelta(hours=24): + del timeouts[username.lower()] + else: + connection.privmsg("You are still on timeout, please come back later") + return + channels = [] for channel in event.args[:-2]: channels.extend([chan.strip() for chan in channel.strip().split(",")]) - bot.logger.debug("-> {} (username: {}) wants to enter {}".format(sent_nick, username, - ", ".join(channels))) + bot.logger.debug( + "-> {} (username: {}) wants to enter {}".format( + sent_nick, + username, + ", ".join(channels) + ) + ) # Pull fresh copy of user, use the cached version if no user is found key = "user_{0}".format(username) user = bot.database.get_user(username) - if user == None: + if user is None: user = bot.cache[key] valid, error = validate_irckey(user, password) if valid: bot.cache.store(key, user, timedelta(30)) connection.send_raw("CHGIDENT {} {}".format(sent_nick, user.ID)) - connection.send_raw("CHGHOST {} {}.{}.{}".format(sent_nick, user.Username, - user.ClassName.replace(" ", ""), - bot.config.site.tld)) + connection.send_raw("CHGHOST {} {}.{}.{}".format( + sent_nick, + user.Username, + user.ClassName.replace(" ", ""), + bot.config.site.tld + )) joined = [] not_real = [] not_joined = [] @@ -117,3 +137,15 @@ def enter(bot, connection, event): bot.logger.debug("-> {} entered #{}".format(sent_nick, ", #".join(joined))) else: connection.privmsg(sent_nick, error) + + +@privmsg() +@admin_only() +@command("timeout") +def timeout(bot, connection, event): + global timeouts + + client_name = event.args[-2] + site_name = event.args[-1] + connection.send_raw("KILL {} Been placed on a 1 day timeout".format(client_name)) + timeouts[site_name.lower()] = datetime.now() diff --git a/hermes/modules/fls.py b/hermes/modules/fls.py index 6c38ed9..dc39762 100644 --- a/hermes/modules/fls.py +++ b/hermes/modules/fls.py @@ -15,10 +15,10 @@ def setup(bot): @event("pubmsg", "privmsg") -@rule(r'[!\.][a-zA-Z0-9]+') +@rule(r'^[!\.][a-zA-Z0-9]+') def can_trigger(bot, connection, event, match): trigger = event.cmd.lower().strip('!.') - target = event.source.nick if event.type == "privmsg" else event.target + target = event.source.nick if event.type == 'privmsg' else event.target if trigger in bot.storage[key]: connection.privmsg(target, bot.storage[key][trigger]) @@ -61,16 +61,13 @@ def can_add(bot, connection, event, args): trigger = None if args is None or len(args) == 0 else args[0] message = None if args is None or len(args) < 2 else args[1:] if trigger is None or message is None: - connection.notice(event.source.nick, "Please specify a trigger and \ - message.") + connection.notice(event.source.nick, "Please specify a trigger and message.") return if trigger in bot.storage[key]: - connection.privmsg(event.source.nick, "Trigger {0} updated.".format( - trigger)) + connection.notice(event.source.nick, "Trigger {0} updated.".format(trigger)) else: - connection.privmsg(event.source.nick, "Trigger {0} added.".format( - trigger)) + connection.notice(event.source.nick, "Trigger {0} added.".format(trigger)) bot.storage[key][trigger] = " ".join(message) @@ -82,12 +79,10 @@ def can_del(bot, connection, event, args): return if trigger in bot.storage[key]: - connection.notice(event.source.nick, "Trigger {0} deleted.".format( - trigger)) + connection.notice(event.source.nick, "Trigger {0} deleted.".format(trigger)) del bot.storage[key][trigger] else: - connection.notice(event.source.nick, "Couldn't find trigger {0}.".format( - trigger)) + connection.notice(event.source.nick, "Couldn't find trigger {0}.".format(trigger)) def can_list(bot, connection, event, args): @@ -107,7 +102,8 @@ def check_auth(bot, connection, host, nick, prompt): user = bot.database.get_user(split_host[0]) if user is None: if prompt: - connection.notice(nick, "You must be authed through the bot to administer canned responses.") + connection.notice(nick, "You must be authed through the bot to administer \ + canned responses.") return False if bot.config.fls.class_id in user['SecondaryClasses'] or \ diff --git a/hermes/modules/interview.py b/hermes/modules/interview.py index 2c86f2e..f3137a8 100644 --- a/hermes/modules/interview.py +++ b/hermes/modules/interview.py @@ -6,11 +6,14 @@ from hermes.module import event, command, disabled, admin_only from time import time import re -key = "interview_queue" +key = 'interview_queue' +speedtest_key = 'speedtest_history' def setup(bot): if bot.storage[key] is None: bot.storage[key] = list() + if bot.storage[speedtest_key] is None: + bot.storage[speedtest_key] = list() def convert_time(diff): @@ -31,11 +34,11 @@ class UserClass: self.time = time() self.speed_test = speed_test self.postponed = False - + def get_waited(self): waited = time() - self.time return convert_time(waited) - + def get_waited_str(self): days, hours, minutes, seconds = self.get_waited() return "{} days, {} hours, {} minutes, and " \ @@ -56,6 +59,10 @@ def is_in_queue(bot, user, host): return is_already_in_queue, position +def is_url_reused(bot, url): + return url in bot.storage[speedtest_key] + + def check_auth(bot, connection, host, nick, prompt): split_host = host.split(".") if len(split_host) != 4: @@ -140,6 +147,11 @@ def queue(bot, connection, event): connection.notice(nick, "You are already in the queue at position " "{}.".format(str(position + 1))) else: + if is_url_reused(bot, speed_test): + connection.notice(nick, "Speedtest URL has already been used, take another") + return + + bot.storage[speedtest_key].append(speed_test) bot.storage[key].append(UserClass(nick, host, user, speed_test)) connection.notice(nick, "Successfully added to queue. You are at position " "{}.".format(str(len(bot.storage[key])))) @@ -222,9 +234,9 @@ def postpone(bot, connection, event): :param bot: :type bot: hermes.Hermes - :param connection: + :param connection: :param event: - :return: + :return: """ nick = event.source.nick user = event.source.user @@ -251,9 +263,9 @@ def cancel(bot, connection, event): :param bot: :type bot: hermes.Hermes - :param connection: + :param connection: :param event: - :return: + :return: """ nick = event.source.nick diff --git a/hermes/modules/orpheus.py b/hermes/modules/orpheus.py new file mode 100644 index 0000000..61cd1b2 --- /dev/null +++ b/hermes/modules/orpheus.py @@ -0,0 +1,112 @@ +from re import IGNORECASE +from hermes.module import rule, event, disabled + +def check_perms(bot, channel, level): + channel = channel.lstrip('#').lower() + if channel not in bot.config.irc.channels: + return False + config_channel = bot.config.irc.channels[channel] + if 'min_level' not in config_channel or level > config_channel.min_level or ( + 'public' in config_channel and config_channel.public == True): + return False + + return True + +@event("pubmsg") +@rule(r"https:\/\/orpheus\.network\/forums\.php\?[a-zA-Z0-9=&]*threadid=([0-9]+)", IGNORECASE) +def parse_thread_url(bot, connection, event, match): + """ + + :param bot: + :type bot: hermes.Hermes + :param connection: + :param event: + :param match: + :return: + """ + topic = bot.database.get_topic(int(match.group(1))) + if topic is not None: + if not check_perms(bot, event.target, topic.MinClassRead): + return + + msg = "[ Forums :: {0} | {1} ]".format(topic.Forum, topic.Title) + connection.privmsg(event.target, msg) + + +@event("pubmsg") +@rule(r"https:\/\/orpheus\.network\/wiki\.php\?[a-zA-Z0-9=&]*id=([0-9]+)") +def parse_wiki_url(bot, connection, event, match): + wiki = bot.database.get_wiki(int(match.group(1))) + if wiki is not None: + if not check_perms(bot, event.target, wiki.MinClassRead): + return + + msg = "[ Wiki :: {0} ]".format(wiki.Title) + connection.privmsg(event.target, msg) + + +@event("pubmsg") +@rule(r"https:\/\/orpheus\.network\/user\.php\?[a-zA-Z0-9=&]*id=([0-9]+)") +def parse_user_url(bot, connection, event, match): + user = bot.database.get_user(int(match.group(1))) + if user is not None: + msg = "[ {0} ] :: [ {1} ] :: [ Uploaded: {2} | Downloaded: {3} | " \ + "Ratio: {4} ]".format(user.Username, user.ClassName, user.DisplayStats.Uploaded, + user.DisplayStats.Downloaded, user.DisplayStats.Ratio) + connection.privmsg(event.target, msg) + + +@event("pubmsg") +@rule(r"https:\/\/orpheus\.network\/requests\.php\?[a-zA-Z0-9=&]*id=([0-9]+)") +def parse_request_url(bot, connection, event, match): + request = bot.database.get_request(int(match.group(1))) + if request is not None: + msg = "[ Request :: {0} - {1} ({2}) ]".format(request.DisplayArtists, request.Title, + request.Year) + connection.privmsg(event.target, msg) + + +@event("pubmsg") +@rule(r"https:\/\/orpheus\.network\/torrents\.php\?[a-zA-Z0-9=&]*torrentid=([0-9]+)") +def parse_torrent_url(bot, connection, event, match): + torrent = bot.database.get_torrent(int(match.group(1))) + if torrent is not None: + if torrent.HasLogDB == "1": + log = " {0}%".format(torrent.LogScore) + elif torrent.HasLog == "1": + log = " Log" + else: + log = "" + + msg = "[ Torrent :: {0} - {1} ({2}) [{3}] | {4} {5}{6} ]".format(torrent.DisplayArtists, + torrent.Name, torrent.Year, torrent.ReleaseType, torrent.Media, torrent.Format, + log) + connection.privmsg(event.target, msg) + + +@event("pubmsg") +@rule(r"https:\/\/orpheus\.network\/torrents\.php[a-zA-Z0-9=&\?]*[\?&]id=([0-9]+)$") +def parse_torrent_group_url(bot, connection, event, match): + group = bot.database.get_torrent_group(int(match.group(1))) + if group is not None: + msg = "[ Torrent :: {0} - {1} ({2}) [{3}] ]".format(group.DisplayArtists, group.Name, + group.Year, group.ReleaseType) + connection.privmsg(event.target, msg) + + +@event("pubmsg") +@rule(r"https:\/\/orpheus\.network\/artist\.php\?[a-zA-Z0-9=&]*id=([0-9]+)") +def parse_artist_url(bot, connection, event, match): + artist = bot.database.get_artist(int(match.group(1))) + if artist is not None: + msg = "[ Artist :: {0} ]".format(artist.Name) + connection.privmsg(event.target, msg) + + +@event("pubmsg") +@rule(r"https:\/\/orpheus\.network\/collages\.php\?[a-zA-Z0-9=&]*id=([0-9]+)") +def parse_collage_url(bot, connection, event, match): + collage = bot.database.get_collage(int(match.group(1))) + if collage is not None: + msg = "[ Collage :: {0} [{1}] ]".format(collage.Name, collage.Category) + connection.privmsg(event.target, msg) diff --git a/hermes/modules/quotes.py b/hermes/modules/quotes.py new file mode 100644 index 0000000..5c6e525 --- /dev/null +++ b/hermes/modules/quotes.py @@ -0,0 +1,110 @@ +""" +Module to provide randomised quotes. +Administration is available via PM from staff +""" +import re +import random + +from hermes.module import event, command, rule +from pprint import pprint, pformat + +key = "quotes" + +def setup(bot): + if bot.storage[key] is None: + bot.storage[key] = dict() + + +@event("pubmsg", "privmsg") +@command("quote") +def quote_admin(bot, connection, event): + """ + + :param bot: + :type bot: hermes.Hermes + :param connection: + :param event: + :return: + """ + nick = event.source.nick + user = event.source.user + host = event.source.host + chan = event.target + + if event.type == 'pubmsg': + target = event.source.nick if event.type == 'privmsg' else event.target + if len(bot.storage[key]) > 0: + connection.privmsg(target, random.choice(list(bot.storage[key].values()))) + return + + if not check_auth(bot, connection, host, nick, False): + return + + command = None if len(event.args) == 0 else event.args[0] + args = None if len(event.args) < 2 else event.args[1:] + + if command == 'add': + quote_add(bot, connection, event, args) + elif command == 'del': + quote_del(bot, connection, event, args) + elif command == 'list' or command is None: + quote_list(bot, connection, event, args) + + +def quote_add(bot, connection, event, args): + trigger = None if args is None or len(args) == 0 else args[0] + message = None if args is None or len(args) < 2 else args[1:] + if trigger is None or message is None: + connection.notice(event.source.nick, "Please specify a name and message.") + return + + if trigger in bot.storage[key]: + connection.privmsg(event.source.nick, "Quote {0} updated.".format(trigger)) + else: + connection.privmsg(event.source.nick, "Quote {0} added.".format(trigger)) + + bot.storage[key][trigger] = " ".join(message) + + +def quote_del(bot, connection, event, args): + trigger = None if args is None or len(args) == 0 else args[0] + if trigger is None: + connection.notice(event.source.nick, "Please specify a name.") + return + + if trigger in bot.storage[key]: + connection.notice(event.source.nick, "Quote {0} deleted.".format(trigger)) + del bot.storage[key][trigger] + else: + connection.notice(event.source.nick, "Couldn't find quote {0}.".format(trigger)) + + +def quote_list(bot, connection, event, args): + connection.notice(event.source.nick, "Quotes:") + for trigger in bot.storage[key].keys(): + connection.notice(event.source.nick, "{0}: {1}".format(trigger, + bot.storage[key][trigger])) + + +def check_auth(bot, connection, host, nick, prompt): + split_host = host.split(".") + if len(split_host) != 4: + return False + + if host.endswith(bot.config.site.tld): + # Make sure that the one issuing the command is authorized to do so + user = bot.database.get_user(split_host[0]) + if user is None: + if prompt: + connection.notice(nick, "You must be authed through the bot to administer \ + quotes.") + return False + + if user['Level'] >= bot.config.quote.min_level: + return True + else: + if prompt: + connection.notice(nick, "You are not authorized to do this command!") + return False + + diff --git a/hermes/modules/user.py b/hermes/modules/user.py index 6487350..8a7dc47 100644 --- a/hermes/modules/user.py +++ b/hermes/modules/user.py @@ -23,7 +23,8 @@ def show_user(bot, connection, event): chan = event.target.lstrip('#').lower() if chan in bot.config.irc.channels: - if 'min_level' not in bot.config.irc.channels[chan]: + if 'public' in bot.config.irc.channels[chan] and \ + bot.config.irc.channels[chan].public == True: return else: return diff --git a/hermes/modules/youtube.py b/hermes/modules/youtube.py index 7b6bc8c..d75fd49 100644 --- a/hermes/modules/youtube.py +++ b/hermes/modules/youtube.py @@ -18,13 +18,13 @@ from hermes.module import event, rule, disabled @event("privmsg", "pubmsg") def parse_youtube(bot, connection, event, match): """ - - :param bot: + + :param bot: :type bot: hermes.Hermes - :param connection: - :param event: - :param match: - :return: + :param connection: + :param event: + :param match: + :return: """ if not hasattr(bot.config, "youtube_api"): return @@ -44,4 +44,4 @@ def parse_youtube(bot, connection, event, match): title = str(video['snippet']['title']) views = locale.format("%d", int(video['statistics']['viewCount']), grouping=True) msg = "[ {} | {} views | https://www.youtube.com/watch?v={} ]".format(title, views, video_id) - connection.privmsg(target, msg) \ No newline at end of file + connection.privmsg(target, msg) diff --git a/hermes/utils.py b/hermes/utils.py index 9fcfbb1..d6edf62 100644 --- a/hermes/utils.py +++ b/hermes/utils.py @@ -47,13 +47,16 @@ def get_git_hash(): :return: str hash assuming hermes is in git repo, otherwise None """ git_hash = None - if os.path.isdir(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", ".git")): - current_dir = os.getcwd() - git_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", ) - os.chdir(git_dir) - out, _ = run_popen("git rev-parse HEAD --short") - os.chdir(current_dir) - git_hash = str(out, 'utf-8').strip() + git_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..") + try: + if os.path.isdir(os.path.join(git_dir, ".git")): + current_dir = os.getcwd() + os.chdir(git_dir) + out, _ = run_popen("git rev-parse HEAD --short") + os.chdir(current_dir) + git_hash = str(out, 'utf-8').strip() + except FileNotFoundError: + pass return git_hash @@ -101,7 +104,7 @@ class DotDict(dict): __setattr__ = dict.__setitem__ __delattr__ = dict.__delitem__ __contains__ = dict.__contains__ - + def __getstate__(self): return self.__dict__ def __setstate__(self, d): diff --git a/run_hermes b/run_hermes old mode 100755 new mode 100644