This commit is contained in:
itismadness
2020-10-11 15:29:34 +00:00
parent 463a7fcb9e
commit 45dedd576f
22 changed files with 806 additions and 203 deletions

3
.flake8 Normal file
View File

@@ -0,0 +1,3 @@
[flake8]
exclude = .git
max-line-length = 88

15
Dockerfile Normal file
View File

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

View File

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

0
bin/hermes Executable file → Normal file
View File

28
cache_view Normal file
View File

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

View File

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

148
hermes/api.py Normal file
View File

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

View File

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

View File

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

View File

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

75
hermes/irc.py Normal file
View File

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

View File

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

View File

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

View File

@@ -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 <site_username> <site_irckey> <channels>",
"enter #APOLLO itismadness 123456",
"enter #APOLLO #announce itismadness 123456",
"enter #APOLLO,#announce itismadness 123456")
@example("enter <channels> <site_username> <site_irckey>",
"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()

View File

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

View File

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

112
hermes/modules/orpheus.py Normal file
View File

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

110
hermes/modules/quotes.py Normal file
View File

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

View File

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

View File

@@ -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)
connection.privmsg(target, msg)

View File

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

0
run_hermes Executable file → Normal file
View File