Initial release of Hermes

This commit is contained in:
itismadness committed 2018-11-02 15:53:52 -04:00
commit 463a7fcb9e
28 files changed
+2095

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
.idea/
.DS_STORE
config.yml
*.py[cso]
build/
dist/
*.egg-info/
*.swp
+40
View File
@@ -0,0 +1,40 @@
Hermes
======
An IRC bot built for the next generation of Gazelle sites. It takes ideas and inspiration from
[zookeeper](https://github.com/phracker/zookeeper), while providing a much more secure, stable, and extendable
platform from which someone can build for their needs.
The bot is written for Python 3.
Installation
------------
To install, run the setup.py file::
python3 setup.py install
This will install the bot, and its dependencies, and create a script in /usr/local/bin (or wherever Python is
setup to create new bin files) to run the bot through the "hermes" command.
Usage
-----
Running hermes::
bin/hermes
Hermes primarily runs itself out of the ~/.hermes directory of the user that is running it. Inside, it expects to
find a `config.yml` file (explained below), and will log output (if run under the -v or -vv flags) and also
load any `.py` files found in `~/.hermes/modules` directory. The bin script will also create a `hermes.pid` file
in that directory to prevent multiple instances of the bot from running.
Configuration
-------------
The bot is managed through the `config.yml` file (a sample one is provided at `config.yml.sample`). It currently
does not make sure that you have all settings set appropriately, so do take care. You just need to copy the example
to `~/.hermes/config.yml`, and change it as necessary. It should be relatively straightforward to do.
Executable
+20
View File
@@ -0,0 +1,20 @@
#! /usr/bin/env python3
import os
from hermes import run_hermes
from hermes.hermes import HERMES_DIR
PID = str(os.getpid())
os.makedirs(HERMES_DIR, exist_ok=True)
PIDFILE = os.path.join(HERMES_DIR, "hermes.pid")
if __name__ == "__main__":
if os.path.isfile(PIDFILE):
print("{} already exists, exiting".format(PIDFILE))
raise SystemExit
with open(PIDFILE, 'w') as open_pidfile:
open_pidfile.write(PID)
try:
run_hermes()
finally:
os.unlink(PIDFILE)
+47
View File
@@ -0,0 +1,47 @@
nick: hermes
name: hermes bot
site:
tld: apollo.rip
url: https://apollo.rip
irc:
host: 127.0.0.1
port: 6667
channels:
apollo:
name: apollo
devs:
name: devs
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
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
channels:
- interview
- interview2
- interview3
- interview4
- interview5
- interview6
- interview7
- interview8
- interview9
persist:
path: "!HERMES!/persist.dat"
admins:
- itismadness
View File
Whitespace-only changes.
+5
View File
@@ -0,0 +1,5 @@
"""
Hermes module
"""
from .hermes import run_hermes, Hermes
+69
View File
@@ -0,0 +1,69 @@
"""
Ephemeral storage of arbitrary data.
"""
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:
self.storage = storage
else:
self.storage = DotDict()
if expiry is not None:
self.expiry = expiry
else:
self.expiry = timedelta(7)
def __iter__(self):
return self.storage.__iter__()
def __next__(self):
return self.storage.__next__()
def __getitem__(self, key):
return self.get(key)
def __setitem__(self, key, value):
self.store(key, value)
def __len__(self):
return len(self.storage)
def __contains__(self, key):
return key in self.storage
def store(self, key, value, expiry=None):
if not expiry:
expiry = self.expiry
self.storage[key] = CacheObject(value, datetime.now() + expiry)
def get(self, key):
if key in self.storage:
if self.storage[key].expiry > datetime.now():
return self.storage[key].value
else:
del self.storage[key]
return None
def clear(self, key=None):
if key:
if key in self.storage:
del self.storage[key]
else:
self.storage.clear()
def expire(self):
for key in self.storage:
if self.storage[key].expiry < datetime.now():
del self.storage[key]
+168
View File
@@ -0,0 +1,168 @@
"""
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
from sqlalchemy.ext.declarative import declarative_base
import sqlalchemy.dialects.mysql as mysql
from sqlalchemy.orm import sessionmaker, relationship
from phpserialize import unserialize
from hermes.utils import dict_to_list
BASE = declarative_base()
class GazelleDB(object):
"""
Primary class that is used by hermes to load the DB, and manage it's connection. We then
define functions for the interactions that we expect to have with the DB as well such that
we are not directly exposing the interface to the end-user so that we're not allowing
any arbitrary statement in any of the arbitrary modules.
"""
def __init__(self, host, db_name, username, password):
self._engine = create_engine("mysql+pymysql://{}:{}@{}:33060/{}"
.format(username, password, host, db_name), pool_recycle=3600)
self._session_maker = sessionmaker(bind=self._engine, autocommit=True)
self._session = self._session_maker()
def get_user(self, username):
"""
Given a username, get the User that it matches, else return None
:param username:
:return: User that the username belongs to if one exists
:rtype: User
"""
return self._session.query(User).filter_by(username=username).first()
def get_topic(self, topic_id):
"""
Given a topic id, get the Topic that it matches, else return None
:param topic_id:
:return: ForumTopics that topic_id belongs to if one exists
:rtype: ForumTopics
"""
return self._session.query(ForumTopics).filter_by(id=int(topic_id)).first()
def disconnect(self):
self._session.close()
self._engine.dispose()
class ForumTopics(BASE):
__tablename__ = "forums_topics"
id = Column('ID', mysql.INTEGER(10, unsigned=True), primary_key=True, autoincrement=True)
title = Column('Title', mysql.VARCHAR(150))
author_id = Column('AuthorID', mysql.INTEGER(10))
is_locked = Column('IsLocked', mysql.ENUM('0', '1'), default='0')
is_sticky = Column('IsSticky', mysql.ENUM('0', '1'), default='1')
forum_id = Column('ForumID', mysql.INTEGER(3))
num_posts = Column('NumPosts', mysql.INTEGER(10))
last_post_id = Column('LastPostID', mysql.INTEGER(10))
last_post_time = Column('LastPostTime', mysql.DATETIME())
last_post_author_id = Column('LastPostAuthorID', mysql.INTEGER(10))
sticky_post_id = Column('StickyPostID', mysql.INTEGER(10))
ranking = Column('Ranking', mysql.TINYINT(2))
created_time = Column('CreatedTime', mysql.DATETIME())
class Permissions(BASE):
__tablename__ = "permissions"
id = Column('ID', mysql.INTEGER(10, unsigned=True), primary_key=True, autoincrement=True)
level = Column('Level', mysql.INTEGER(10, unsigned=True), unique=True)
name = Column('Name', mysql.VARCHAR(25))
values = Column('Values', mysql.TEXT)
display_staff = Column('DisplayStaff', mysql.ENUM('0', '1'), default='0')
permitted_forums = Column('PermittedForums', mysql.VARCHAR(150))
secondary = Column('Secondary', mysql.TINYINT(4))
class UserInfo(BASE):
__tablename__ = "users_info"
user_id = Column("UserID", mysql.INTEGER(10, unsigned=True), ForeignKey('users_main.ID'),
primary_key=True)
style_id = Column("StyleID", mysql.INTEGER(10, unsigned=True))
style_url = Column("StyleURL", mysql.VARCHAR(255))
info = Column("Info", mysql.TEXT)
avatar = Column("Avatar", mysql.VARCHAR(255))
admin_comments = Column("AdminComment", mysql.TEXT)
site_options = Column("SiteOptions", mysql.TEXT)
view_avatars = Column("ViewAvatars", mysql.ENUM('0', '1'))
donor = Column('Donor', mysql.ENUM('0', '1'))
artist = Column('Artist', mysql.ENUM('0', '1'))
download_alt = Column('DownloadAlt', mysql.ENUM('0', '1'))
warned = Column('Warned', mysql.DATETIME)
support_for = Column('SupportFor', mysql.VARCHAR(255))
torrent_grouping = Column('TorrentGrouping', mysql.ENUM('0', '1', '2'))
show_tags = Column('ShowTags', mysql.ENUM('0', '1'))
notify_on_quote = Column('NotifyOnQuote', mysql.ENUM('0', '1', '2'))
auth_key = Column('AuthKey', mysql.VARCHAR(32))
reset_key = Column('ResetKey', mysql.VARCHAR(32))
reset_expires = Column('ResetExpires', mysql.DATETIME)
join_date = Column('JoinDate', mysql.DATETIME)
inviter = Column('Inviter', mysql.INTEGER(10))
bitcoin_address = Column('BitcoinAddress', mysql.VARCHAR(34))
warned_times = Column('WarnedTimes', mysql.INTEGER(2))
disable_avatar = Column('DisableAvatar', mysql.ENUM('0', '1'))
disable_posting = Column('DisableInvites', mysql.ENUM('0', '1'))
disable_forums = Column('DisableForums', mysql.ENUM('0', '1'))
disable_irc = Column('DisableIRC', mysql.ENUM('0', '1'))
disable_tagging = Column('DisableTagging', mysql.ENUM('0', '1'))
disable_upload = Column('DisableUpload', mysql.ENUM('0', '1'))
disable_wiki = Column('DisableWiki', mysql.ENUM('0', '1'))
disable_pm = Column('DisablePM', mysql.ENUM('0', '1'))
ratio_watch_ends = Column('RatioWatchEnds', mysql.DATETIME)
ratio_watch_download = Column('RatioWatchDownload', mysql.BIGINT(20, unsigned=True))
ratio_watch_times = Column('RatioWatchTimes', mysql.TINYINT(1, unsigned=True))
ban_date = Column('BanDate', mysql.DATETIME)
ban_reason = Column('BanReason', mysql.ENUM('0', '1', '2', '3', '4'))
catchup_time = Column('CatchupTime', mysql.DATETIME)
last_read_news = Column('LastReadNews', mysql.INTEGER(10))
hide_country_changes = Column('HideCountryChanges', mysql.ENUM('0', '1'))
restricted_forums = Column('RestrictedForums', mysql.VARCHAR(150))
disable_requests = Column('DisableRequests', mysql.ENUM('0', '1'))
permitted_forums = Column('PermittedForums', mysql.VARCHAR(150))
unseeded_alerts = Column('UnseededAlerts', mysql.ENUM('0', '1'))
last_read_blog = Column('LastReadBlog', mysql.ENUM('0', '1'))
info_title = Column('InfoTitle', mysql.VARCHAR(255))
class User(BASE):
__tablename__ = "users_main"
id = Column("ID", mysql.INTEGER(10, unsigned=True), primary_key=True)
username = Column('Username', mysql.VARCHAR(20))
email = Column('Email', mysql.VARCHAR(255))
pass_hash = Column('PassHash', mysql.VARCHAR(60))
secret = Column('Secret', mysql.VARCHAR(32))
irc_key = Column('IRCKey', mysql.CHAR(32))
last_login = Column('LastLogin', mysql.DATETIME)
last_access = Column('LastAccess', mysql.DATETIME)
ip = Column('IP', mysql.VARCHAR(15))
class_id = Column('Class', mysql.TINYINT(2))
uploaded = Column('Uploaded', mysql.BIGINT(20, unsigned=True))
downloaded = Column('Downloaded', mysql.BIGINT(20, unsigned=True))
title = Column('Title', mysql.TEXT)
enabled = Column('Enabled', mysql.ENUM('0', '1', '2'))
paranoia = Column('Paranoia', mysql.TEXT)
visible = Column('Visible', mysql.ENUM('1', '0'))
invites = Column('Invites', mysql.INTEGER(10, unsigned=True))
permission_id = Column('PermissionID', mysql.INTEGER(10, unsigned=True),
ForeignKey('permissions.ID'))
custom_permissions = Column('CustomPermissions', mysql.TEXT)
can_leech = Column('can_leech', mysql.TINYINT(4))
torrent_pass = Column('torrent_pass', mysql.CHAR(32))
required_ratio = Column('RequiredRatio', mysql.DOUBLE(10, 8))
required_ratio_work = Column('RequiredRatioWork', mysql.DOUBLE(10, 8))
ipcc = Column('ipcc', mysql.VARCHAR(2))
fl_tokens = Column('FLTokens', mysql.INTEGER(10))
permission_class = relationship("Permissions")
info = relationship("UsersInfo")
def get_paranoia(self):
return dict_to_list(unserialize(bytes(str(self.paranoia), 'utf-8')))
+10
View File
@@ -0,0 +1,10 @@
"""
Module that contains all of the different types of events that hermes can act upon. Useful just
for ensuring consistency in reference to the events across the hermes codebase. The events should
always be lowercased with an uppercase const reference.
"""
class Events(object):
PRIVMSG = "privmsg"
PUBMSG = "pubmsg"
+454
View File
@@ -0,0 +1,454 @@
# -*- 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.
"""
import argparse
import locale
import logging
import os
import random
import re
import signal
import socket
import ssl
import sys
import threading
import time
from irc.bot import SingleServerIRCBot
from irc.connection import Factory
from .database import GazelleDB
from .loader import load_modules
from .utils import get_git_hash, check_pid, load_config, DotDict
from .cache import Cache
from .persist import PersistentStorage
locale.setlocale(locale.LC_ALL, 'en_US.utf8')
__version__ = "0.2.0"
LOGGER = logging.getLogger("hermes")
HERMES_DIR = os.path.join(os.path.expanduser("~"), ".hermes")
def set_verbosity(verbose=0, level=logging.INFO):
log_format = logging.Formatter("%(asctime)s [%(levelname)-5.5s] %(message)s")
if verbose >= 1:
file_logger = logging.FileHandler(os.path.join(HERMES_DIR, "hermes.log"))
file_logger.setFormatter(log_format)
file_logger.setLevel(level)
LOGGER.addHandler(file_logger)
if verbose == 2:
console_logger = logging.StreamHandler(sys.stdout)
console_logger.setFormatter(log_format)
console_logger.setLevel(level)
LOGGER.addHandler(console_logger)
else:
LOGGER.addHandler(logging.NullHandler())
LOGGER.setLevel(level)
# noinspection PyMethodMayBeStatic,PyUnusedLocal
class Hermes(SingleServerIRCBot):
def __init__(self):
self.logger = LOGGER
self.dir = HERMES_DIR
self.logger.info("-> Loading Hermes ({})".format(get_version_string()))
if not os.path.isdir(HERMES_DIR):
os.makedirs(HERMES_DIR, exist_ok=True)
self.config = load_config(os.path.join(HERMES_DIR, "config.yml"))
self.logger.info("-> Loaded Config")
self.nick = self.config.nick
self.name = self.config.name if 'name' in self.config else self.nick
self.logger.info("-> Loading Modules")
self.modules = load_modules()
self.logger.info("-> Modules Loaded")
if 'persist' in self.config and 'path' in self.config.persist:
persist_path = self.config.persist.path.replace('!HERMES!', self.dir)
else:
persist_path = os.path.join(self.dir, 'persist.dat')
self.storage = PersistentStorage(persist_path)
self.logger.info("-> Loaded Storage ({0} keys)".format(len(self.storage)))
if 'cache' not in self.storage:
self.storage['cache'] = DotDict()
self.cache = Cache(self.storage['cache'])
self.logger.info("-> Loaded Cache ({0} keys)".format(len(self.cache)))
self.listener = None
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.logger.info("-> Loaded DB")
for name, mod in self.modules.items():
# noinspection PyBroadException
try:
if hasattr(mod, 'setup'):
mod.setup(self)
self.logger.info("Loaded module: {}".format(name))
except:
self.logger.exception("Error Module: {}".format(name))
if 'ssl' in self.config.irc and self.config.irc.ssl is True:
factory = Factory(wrapper=ssl.wrap_socket)
else:
factory = Factory()
super().__init__([(self.config.irc.host, self.config.irc.port)],
'{}{}'.format(self.nick, random.randint(1, 1000)), self.name,
connect_factory=factory)
for attr in ("on_pubmsg", "on_privmsg"):
setattr(self, attr, self._dispatch)
self.logger.info("-> Loaded IRC")
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).
:raises: SystemError
"""
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.
:raises: SystemError
"""
raise SystemError("*** ERROR: Illegal characters in BotNick ***")
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
:param connection:
:param event:
:return:
"""
self.logger.info("-> Connected to {} with nick {}".format(self.config.irc.host,
self.nick))
if hasattr(self.config.irc, "oper"):
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.listener.set_connection(connection)
self.listener.start()
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))
def on_disconnect(self, connection, event):
self.logger.info("-> Disconnected from IRC")
def _execute_function(self, func, connection, event):
cmd = event.cmd
if hasattr(func, "commands"):
for command in func.commands:
if cmd == "." + command or cmd == "!" + command or \
(event.type == "privmsg" and cmd == command):
func(self, connection, event)
if hasattr(func, "rules"):
for rule in func.rules:
match = re.search(rule, event.msg)
if match:
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
def _dispatch(self, connection, event):
"""
:param connection:
: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 -
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)
tags (empty list)
"""
event.msg = event.arguments[0]
args = event.arguments[0].split()
if len(args) == 0:
return
event.cmd = args[0].lower()
event.args = args[1:] if len(args) > 1 else []
for name, mod in self.modules.items():
for func in mod.__callables__:
if func.disabled is True:
continue
elif func.admin_only is True and not self.check_admin(event):
continue
# noinspection PyBroadException
try:
if event.type in func.events:
self._execute_function(func, connection, event)
except:
if event.type == "privmsg":
msg = "I'm sorry, {}.{} threw an exception. Please tell an " \
"administrator and try again later.".format(name, func.__name__)
connection.privmsg(event.source.nick, msg)
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:
self.database.disconnect()
if self.listener is not None:
self.listener.stop()
super(Hermes, self).disconnect(msg)
def restart(self):
self.disconnect()
raise RestartException
class RestartException(Exception):
pass
class BotCheck(threading.Thread):
def __init__(self, bot):
super().__init__()
self.alive = True
self.bot = bot
def run(self):
time.sleep(120)
while self.alive:
if len(self.bot.channels) == 0:
self.bot.logger.info('-> Bot not connected to channels, restarting')
self.bot.restart()
time.sleep(5)
def stop(self):
self.alive = False
self.join()
class SaveData(threading.Thread):
def __init__(self, bot):
super().__init__()
self.alive = True
self.bot = bot
def run(self):
time.sleep(120)
while self.alive:
self.bot.storage.save()
time.sleep(600)
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.
"""
def __init__(self, host, port):
self.logger = LOGGER
self.running = True
self.restart = False
self.connection = None
self.host = host
self.port = port
threading.Thread.__init__(self)
def set_connection(self, connection):
self.connection = connection
def stop(self):
self.running = False
client_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
client_socket.connect((self.host, self.port))
# client_socket.send("QUITTING")
client_socket.close()
def run(self):
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))
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.bind((self.host, self.port))
server_socket.listen(5)
client_socket, address = server_socket.accept()
data = client_socket.recv(512).decode('utf-8').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] == "#":
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]))
server_socket.close()
def get_version_string():
version_string = __version__
git_hash = get_git_hash()
if git_hash is not None:
version_string += "-{}".format(git_hash)
return 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.")
return parser.parse_args()
def run_hermes():
args = _parse_args()
levels = {'debug': logging.DEBUG, 'info': logging.INFO, 'warn': logging.WARN,
'error': logging.ERROR}
log_level = levels[args.log_level]
set_verbosity(args.verbose, log_level)
run_eternal = args.no_eternal is not True
if os.getuid() == 0 or os.geteuid() == 0:
raise SystemExit('Error: Do not run Hermes with root privileges.')
os.makedirs(HERMES_DIR, exist_ok=True)
pidfile = os.path.join(HERMES_DIR, "hermes.pid")
if os.path.isfile(pidfile):
with open(pidfile, 'r') as open_pidfile:
try:
old_pid = int(open_pidfile.read().strip())
except ValueError:
old_pid = None
if old_pid is not None and check_pid(old_pid):
if args.stop:
print("Stopping instance of Hermes ({})".format(old_pid))
os.kill(old_pid, signal.SIGTERM)
elif args.kill:
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()
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
# 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:
raise SystemExit
with open(pidfile, 'w') as open_pidfile:
open_pidfile.write(str(os.getpid()))
last_run = None
try:
hermes = Hermes()
save_thread = SaveData(hermes)
save_thread.start()
#thread = BotCheck(hermes)
#thread.start()
def signal_handler(sig, _):
if sig is signal.SIGTERM:
hermes.disconnect()
else:
hermes.die()
signal.signal(signal.SIGTERM, signal_handler)
#signal.signal(signal.SIGKILL, signal_handler)
while run_eternal:
# noinspection PyBroadException
try:
last_run = time.time()
hermes.start()
except (KeyboardInterrupt, SystemError, SystemExit) as e:
LOGGER.info("-> {}".format(e))
hermes.disconnect("Leaving...")
LOGGER.info("Quitting bot")
#thread.stop()
raise SystemExit
except RestartException:
#thread.stop()
time.sleep(5)
hermes = Hermes()
#thread = BotCheck(hermes)
#thread.start()
hermes.start()
except:
if last_run > time.time() - 5:
hermes.disconnect("Crashed, going offline.")
run_eternal = False
else:
hermes.disconnect("Crashed, going to reboot...")
time.sleep(2)
LOGGER.exception("Crash")
finally:
os.unlink(pidfile)
+84
View File
@@ -0,0 +1,84 @@
"""
Module for that allows for dynamic loading/reloading of modules for hermes to use such that we
don't have to necessarily build all of our functionality into the core of hermes, but rather
in separate files each with its own purpose that we've then registered to be run given a
certain command (like getting a privmsg) or matches some regex rule.
"""
import importlib.util
import inspect
import os
import re
import sys
def load_modules():
"""
Loads all the modules from the the modules/ directory within hermes and then checks if
the ~/hermes/modules folder exists and then loads any modules there as well (if it exists).
Note, we want all modules to have unique names otherwise any in ~/hermes/modules will overwrite
any in the modules/ directory.
:return: a dictionary containing all loaded modules where they key is module name (filename)
and the value is the loaded module definition
"""
modules = {}
modules_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "modules")
modules.update(_get_all_modules(modules_dir))
local_dir = os.path.join(os.path.expanduser("~/hermes"), "modules")
if os.path.isdir(local_dir):
modules.update(_get_all_modules(local_dir))
return modules
def _get_all_modules(directory):
modules = {}
for path in os.listdir(directory):
module_path = os.path.join(directory, path)
if os.path.isfile(module_path) and module_path.endswith(".py"):
load_module(modules, module_path)
return modules
def load_module(modules, module_path):
name = os.path.basename(module_path)[:-3]
spec = importlib.util.spec_from_file_location(name, module_path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
mod.__callables__ = parse_module(mod)
mod.__mod_path__ = module_path
modules[name] = mod
sys.modules[name] = mod
def _parse_callable(obj):
obj.admin_only = getattr(obj, "admin_only", False) is True
obj.disabled = getattr(obj, 'disabled', False) is True
if not hasattr(obj, 'events'):
obj.events = ['pubmsg']
else:
if isinstance(obj.events, str):
obj.events = [obj.events]
obj.events = [event.lower() for event in obj.events]
if hasattr(obj, 'commands'):
if isinstance(obj.commands, str):
obj.commands = [obj.commands]
obj.commands = [command.lower() for command in obj.commands]
if hasattr(obj, 'rules'):
if isinstance(obj.rules, str):
obj.rules = [[obj.rules, 0]]
assert(isinstance(obj.rules, list))
for i in range(len(obj.rules)):
obj.rules[i] = re.compile(*obj.rules[i])
obj.help = getattr(obj, "help", None)
obj.examples = getattr(obj, "examples", [])
return obj
def parse_module(mod):
callables = []
for name, func in inspect.getmembers(mod, inspect.isfunction):
if any(hasattr(func, attr) for attr in ('rules', 'commands')):
callables.append(_parse_callable(func))
return callables
+88
View File
@@ -0,0 +1,88 @@
"""
Module defines various decorators we can assign to given module functions so that we have a nice
pluggable interface for expanding the bots abilities and functions in a way that doesn't actually
mean we have to hack apart the core bot code.
"""
def pubmsg():
"""
Decorator
:return:
"""
def add_attribute(func):
if not hasattr(func, "events"):
func.events = []
func.events.append("pubmsg")
return func
return add_attribute
def privmsg():
"""
Defines a function that should only be run when we've received a private message
:return:
"""
def add_attribute(func):
if not hasattr(func, "events"):
func.events = []
func.events.append("privmsg")
return func
return add_attribute
def command(*commands):
def add_attribute(func):
if not hasattr(func, "commands"):
func.commands = []
func.commands.extend(commands)
return func
return add_attribute
def event(*events):
def add_attribute(func):
if not hasattr(func, "events"):
func.events = []
func.events.extend(events)
return func
return add_attribute
def rule(regex, flags=0):
def add_attribute(func):
if not hasattr(func, "rules"):
func.rules = []
func.rules.append([regex, flags])
return func
return add_attribute
def help_message(message):
def add_attribute(func):
func.help = message
return func
return add_attribute
def example(*examples):
def add_attribute(func):
if not hasattr(func, "examples"):
func.examples = []
func.examples.extend(examples)
return func
return add_attribute
def admin_only():
def add_attribute(func):
func.admin_only = True
return func
return add_attribute
def disabled():
def add_attribute(func):
func.disabled = True
return func
return add_attribute
View File
Whitespace-only changes.
+30
View File
@@ -0,0 +1,30 @@
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)
+88
View File
@@ -0,0 +1,88 @@
"""
Module for governing certain admin commands for the bot such as restarting it, updating it (via
git), killing the bot, and printing out its version.
"""
import os
import sys
from hermes.hermes import get_version_string
from hermes.module import admin_only, privmsg, command
from hermes.utils import run_popen, file_tail
@admin_only()
@privmsg()
@command("restart")
def restart_bot(bot, connection, event):
connection.privmsg(event.source.nick, "Saving persistent data")
bot.logger.info("-> Saving data")
bot.storage.save()
connection.privmsg(event.source.nick, "Restarting the bot now")
bot.logger.info("-> Restarting bot")
pidfile = os.path.join(bot.dir, "hermes.pid")
if os.path.isfile(pidfile):
os.unlink(pidfile)
os.execl(sys.executable, *([sys.executable] + sys.argv))
@admin_only()
@privmsg()
@command('restart_socket')
def restart_socket(bot, connection, event):
if bot.listener is not None:
connection.privmsg(event.source.nick, 'Restarting the socket')
bot.listener.restart = True
else:
connection.privmsg(event.source.nick, 'Not using socket')
@admin_only()
@privmsg()
@command("update")
def update_bot(bot, connection, event):
git_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..")
if os.path.isdir(os.path.join(git_dir, ".git")):
current_dir = os.getcwd()
os.chdir(git_dir)
get_version(bot, connection, event)
out, err = run_popen("git pull")
connection.privmsg(event.source.nick, "Running git pull...")
bot.logger.info("-> Updating bot, running git pull")
bot.logger.info(str(out, "utf-8"))
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)
else:
connection.privmsg(event.source.nick, "Can only update bot if run from git directory.")
@admin_only()
@privmsg()
@command("kill")
def kill_bot(bot, *_):
bot.logger.info("-> Killing bot")
raise SystemExit
@admin_only()
@privmsg()
@command("version")
def get_version(_, connection, event):
connection.privmsg(event.source.nick, "Running version: {}".format(get_version_string()))
@admin_only()
@privmsg()
@command('view_log')
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)
except Exception as e:
connection.privmsg(event.source.nick, e)
+28
View File
@@ -0,0 +1,28 @@
"""
Module to administrate the cache
"""
from hermes.module import event, command, admin_only
@event('privmsg')
@command('cache')
@admin_only()
def cache_control(bot, connection, event):
if len(event.args) < 1:
pass
nick = event.source.nick
command = event.args[0]
if command == 'clear':
if len(event.args) == 1:
bot.cache.clear()
connection.privmsg(nick, "Cache cleared")
else:
for key in event.args[1:]:
bot.cache.clear(key)
connection.privmsg(nick, "Cleared cache keys {0}".format(", ".join(event.args[1:])))
elif command == 'count':
connection.privmsg(nick, "Cache keys: {0}".format(str(len(bot.cache))))
elif command == 'expire':
bot.cache.expire()
connection.privmsg(nick, "Expired cache keys purged")
+119
View File
@@ -0,0 +1,119 @@
"""
Module that allows good-standing members (don't have IRC privileges revoked) to enter the
official IRC channels (which should all be +i and not regularly joinable), as well as to have
their ident and host be changed such that they'll be <user_id>@<user_name>.<group_name>.<site_tld>
which allows for better anonymization than default IRC.
Note, because the bot is using SAJOIN to add users to the channels that they request to enter,
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
def validate_irckey(user, irckey):
if user == None:
return False, "No user found with that name."
else:
if user.Enabled != '1' or user.DisableIRC == '1':
return False, "Your IRC privileges have been disabled. Join #disabled."
elif user.IRCKey == irckey:
return True, ""
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")
def enter(bot, connection, event):
"""
:param bot:
:type bot: hermes.Hermes
:param connection:
:param event:
:return:
"""
sent_nick = event.source.nick
if len(event.args) < 3:
connection.privmsg(sent_nick, "Not enough arguments for enter command")
connection.privmsg(sent_nick, "enter <channels> <username> <password>")
return
username = event.args[-2]
password = event.args[-1]
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)))
# 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:
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))
joined = []
not_real = []
not_joined = []
for channel in channels:
channel = channel.lstrip('#')
real_channel = None
for bot_channel in bot.config.irc.channels:
if channel.lower() == bot_channel.lower():
real_channel = bot_channel
break
# Is this a channel the bot is actively watching and cares about?
if real_channel is not None:
join_channel = True
# Does the channel have a minimum level set and does the user's class exceed it?
if 'min_level' in bot.config.irc.channels[real_channel]:
if bot.config.irc.channels[real_channel].min_level > int(user.Level):
join_channel = False
# Does the channel allow certain classes in the channel and does the user
# have that class as either the primary class or one of his secondary classes?
if not join_channel and 'classes' in bot.config.irc.channels[real_channel]:
# get intersection of the user's primary class, secondary classes, and classes
# that are allowed for the channel
intersect = set(user.SecondaryClasses + [user.Class]) & \
set(bot.config.irc.channels[real_channel].classes)
if len(intersect) > 0:
join_channel = True
if join_channel:
joined.append(real_channel)
connection.send_raw("SAJOIN {} #{}".format(sent_nick, real_channel))
else:
not_joined.append(channel)
else:
not_real.append(channel)
if len(not_real) > 0:
connection.privmsg(sent_nick,
"I'm sorry, the channels #{} do not "
"exist".format(", #".join(not_real)))
if len(not_joined) > 0:
connection.privmsg(sent_nick,
"I'm sorry, you lack the right permissions to "
"join #{}".format(", #".join(not_joined)))
if len(joined) > 0:
connection.privmsg(sent_nick, "Welcome to #{}".format(", #".join(joined)))
bot.logger.debug("-> {} entered #{}".format(sent_nick, ", #".join(joined)))
else:
connection.privmsg(sent_nick, error)
+121
View File
@@ -0,0 +1,121 @@
"""
Module to provide FLS with canned responses.
Administration is available from the designated FLS channel or via PM from FLS / staff
"""
import re
from hermes.module import event, command, rule
from pprint import pprint, pformat
key = "canned_responses"
def setup(bot):
if bot.storage[key] is None:
bot.storage[key] = dict()
@event("pubmsg", "privmsg")
@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
if trigger in bot.storage[key]:
connection.privmsg(target, bot.storage[key][trigger])
@event("pubmsg", "privmsg")
@command("can")
def can_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 == 'privmsg':
if not check_auth(bot, connection, host, nick, False):
return
else:
if not chan.strip('#').lower() == bot.config.fls.channel.lower():
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':
can_add(bot, connection, event, args)
elif command == 'del':
can_del(bot, connection, event, args)
elif command == 'list' or command is None:
can_list(bot, connection, event, args)
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.")
return
if trigger in bot.storage[key]:
connection.privmsg(event.source.nick, "Trigger {0} updated.".format(
trigger))
else:
connection.privmsg(event.source.nick, "Trigger {0} added.".format(
trigger))
bot.storage[key][trigger] = " ".join(message)
def can_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 trigger.")
return
if trigger in bot.storage[key]:
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))
def can_list(bot, connection, event, args):
connection.notice(event.source.nick, "Triggers:")
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 canned responses.")
return False
if bot.config.fls.class_id in user['SecondaryClasses'] or \
user['Level'] >= bot.config.fls.min_level:
return True
else:
if prompt:
connection.notice(nick, "You are not authorized to do this command!")
return False
+50
View File
@@ -0,0 +1,50 @@
"""
This module creates a help screen for the user, displaying all of the commands that the bot can
understand based on the registered modules. It will by default just show the command and whether
it can be issued in pubmsg, privmsg or both. However, if the @help_message and/or @example
annotations are filled out, then it will show those as well as the command.
"""
from hermes.module import event, command, help_message
@event("privmsg", "pubmsg")
@command("help")
@help_message("Display this help message")
def show_help(bot, connection, event):
"""
:param bot:
:type bot: hermes.Hermes
:param connection:
:param event:
:return:
"""
target = event.source.nick
connection.privmsg(target, "Hello, I'm the friendly IRC bot.")
connection.privmsg(target, "The following commands can be private messaged to me:")
connection.privmsg(target, "------------------")
_show_inner_help("privmsg", bot, connection, event)
connection.privmsg(target, "The following commands can be used in public channels:")
connection.privmsg(target, "------------------")
_show_inner_help("pubmsg", bot, connection, event)
def _show_inner_help(type, bot, connection, event):
target = event.source.nick
for name, mod in bot.modules.items():
for func in mod.__callables__:
if type in func.events:
if func.admin_only is True and not bot.check_admin(event):
continue
if hasattr(func, "rules"):
continue
if hasattr(func, "commands"):
connection.privmsg(target,
"Command(s): {}".format(", ".join(func.commands)))
if func.help is not None:
connection.privmsg(target, "Description: {}".format(func.help))
if len(func.examples) > 0:
for example in func.examples:
connection.privmsg(target, "Example: {}".format(example))
connection.privmsg(target, "------------------")
+269
View File
@@ -0,0 +1,269 @@
"""
Module to handle interview queues
"""
from hermes.module import event, command, disabled, admin_only
from time import time
import re
key = "interview_queue"
def setup(bot):
if bot.storage[key] is None:
bot.storage[key] = list()
def convert_time(diff):
seconds = diff % 60
diff = (diff - seconds) / 60
minutes = diff % 60
diff = (diff - minutes) / 60
hours = diff % 24
days = (diff - hours) / 24
return days, hours, minutes, seconds
class UserClass:
def __init__(self, nick, host, user, speed_test):
self.nick = nick
self.host = host
self.user = user
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 " \
"{} seconds".format(str(round(days)), str(round(hours)), str(round(minutes)),
str(round(seconds)))
def get_full_name(self):
return "{}!{}@{}".format(self.nick, self.user, self.host)
def is_in_queue(bot, user, host):
is_already_in_queue = False
position = 0
for i in range(len(bot.storage[key])):
if user == bot.storage[key][i].user and host == bot.storage[key][i].host:
is_already_in_queue = True
position = i
return is_already_in_queue, position
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 start an interview.")
return False
if bot.config.interview.class_id in user['SecondaryClasses'] or \
user['Level'] >= bot.config.interview.min_level:
return True
else:
if prompt:
connection.notice(nick, "You are not authorized to do this command!")
return False
def is_in_channel(user, channel):
for chan_user in channel.users():
if user.nick.lower() == chan_user.lower():
return True
return False
def next_user(bot, connection):
while len(bot.storage[key]) > 0:
user = bot.storage[key].pop(0)
if is_in_channel(user, bot.channels['#recruitment']):
return user
return None
@event("privmsg")
@command("queue")
def queue(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
if check_auth(bot, connection, host, nick, False):
if len(bot.storage[key]) == 0:
connection.notice(nick, "The queue is empty")
else:
connection.notice(nick, "The queue currently has {} people in it:".format(len(bot.storage[key])))
for x in range(len(bot.storage[key])):
person = bot.storage[key][x]
connection.notice(nick, "{}) {} - {}".format(x+1, person.nick, person.get_waited_str()))
else:
if len(event.args) == 0:
connection.notice(nick, "The queue command requires a speedtest URL.")
return
speed_test = event.args[0]
if 'speedtest_urls' in bot.config.interview and isinstance(bot.config.interview.speedtest_urls,
list):
match = next(re.match(regex, speed_test) for regex in bot.config.interview.speedtest_urls)
if match:
if len(match.groups()) > 0:
speed_test = "https://www.speedtest.net/result/{0}.png".format(match.group(1))
else:
connection.notice(nick, "Speedtest URL must be directly to the image, refer to "
"Starting the Interview at {} if you are "
"unsure.".format(bot.config.interview.site))
return
is_already_in_queue, position = is_in_queue(bot, user, host)
if is_already_in_queue:
connection.notice(nick, "You are already in the queue at position "
"{}.".format(str(position + 1)))
else:
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]))))
bot.logger.debug("Successfully queued {} at {}.".format(nick, str(len(bot.storage[key]))))
@event("privmsg")
@command("info")
def info(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
is_already_in_queue, position = is_in_queue(bot, user, host)
if is_already_in_queue:
connection.notice(nick, "You are in the queue at position {}.".format(str(position + 1)))
connection.notice(nick, "You have waited for {}.".format(bot.storage[key][position].get_waited_str()))
else:
connection.notice(nick, "You are not in the queue. To enter it type "
"!queue <speedtest URL>.")
@event("privmsg", "pubmsg")
@command("next")
def next_interview(bot, connection, event):
"""
:param bot:
:type bot: hermes.Hermes
:param connection:
:param event:
:return:
"""
nick = event.source.nick
host = event.source.host
if len(event.args) == 0:
connection.notice(nick, "The next command requires a channel name.")
return
chan = event.args[0].lstrip('#')
if check_auth(bot, connection, host, nick, True):
if chan.lower() not in bot.config.interview.channels:
connection.notice(nick, "Must use an official interview channel: #{}".format(
", #".join(bot.config.interview.channels)))
return
user = next_user(bot, connection)
if not user:
connection.notice(nick, "Cannot perform command, queue is empty.")
else:
connection.notice(nick, "Next candidate is {}. Speedtest URL is "
"{}.".format(user.get_full_name(), user.speed_test))
connection.send_raw("SAJOIN {} #{}".format(user.nick, chan))
connection.privmsg(user.nick, "You have been have been invited to take your "
"interview in #{}.".format(chan))
@event("privmsg")
@command("queue_length")
def queue_length(bot, connection, event):
connection.notice(event.source.nick, "The queue currently has {} people "
"in it.".format(len(bot.storage[key])))
@event("privmsg")
@command("postpone")
def postpone(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
is_already_in_queue, position = is_in_queue(bot, user, host)
if is_already_in_queue:
if position + 1 == len(bot.storage[key]):
connection.notice(nick, "You are already at the end of the queue.")
else:
user = bot.storage[key].pop(position)
bot.storage[key].insert(position + 1, user)
connection.notice(nick, "You are now in the queue at position "
"{}.".format(str(position + 2)))
else:
connection.notice(nick, "You are not in the queue. To enter it type "
"!queue <speedtest URL>.")
@event("privmsg")
@command("cancel")
def cancel(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
is_already_in_queue, position = is_in_queue(bot, user, host)
if is_already_in_queue:
bot.storage[key].pop(position)
connection.notice(nick, "Successfully removed you from the queue.")
else:
connection.notice(nick, "You have not been added to the queue. To enter it type "
"!queue <speedtest URL>.")
+43
View File
@@ -0,0 +1,43 @@
"""
Admin module that helps to deal with other modules.
"""
from hermes.module import privmsg, command, admin_only
from hermes.loader import load_module
@admin_only()
@privmsg()
@command("list_modules")
def list_modules(bot, connection, event):
connection.privmsg(event.source.nick, "Loaded modules:")
for mod in bot.modules:
connection.privmsg(event.source.nick, "-> {}".format(mod))
@admin_only()
@privmsg()
@command("reload")
def reload_module(bot, connection, event):
"""
:param bot:
:type bot: hermes.Hermes
:param connection:
:param event:
:return:
"""
modules = [arg.lower() for arg in event.args]
for mod in modules:
if mod not in bot.modules:
connection.privmsg(event.source.nick, "Unknown module: {}".format(mod))
continue
# noinspection PyBroadException
try:
load_module(bot.modules, bot.modules[mod].__mod_path__)
if hasattr(bot.modules[mod], 'setup'):
bot.modules[mod].setup(bot)
bot.logger.info("-> Reloaded module: {}".format(mod))
connection.privmsg(event.source.nick, "Reloaded module: {}".format(mod))
except:
bot.logger.exception("-> Failed to reload module: {}".format(mod))
connection.privmsg(event.source.nick, "Failed to reload module: {}".format(mod))
+55
View File
@@ -0,0 +1,55 @@
"""
Module to allow people to ask to see the core stats of someone through pubmsg or privmsg. This
will display someone's Upload, Download, Ratio, Bonus Points, and link to profile, assuming
that their paranoia is set sufficiently low enough that it is viewable (we don't check to see
if the requesting party has the right permissions to override paranoia if asking in a privmsg)
"""
from hermes.module import event, command
@event("pubmsg")
@command("user", "u")
def show_user(bot, connection, event):
"""
:param bot:
:type bot: hermes.Hermes
:param connection:
:param event:
:return:
"""
chan = event.target.lstrip('#').lower()
if chan in bot.config.irc.channels:
if 'min_level' not in bot.config.irc.channels[chan]:
return
else:
return
if len(event.args) == 0:
# We can parse the username off the host string, but only if it's already been set by the
# bot
if not event.source.host.endswith(bot.config.site.tld):
username = event.source.nick
else:
username = event.source.host.split(".")[0]
else:
username = event.args[0]
if username is None:
connection.privmsg(event.target, "No user found with name {}".format(username))
return
user = bot.database.get_user(username)
if user is None or 'DisplayStats' not in user:
connection.privmsg(event.target, "No user found with name {}".format(username))
return
msg = "[ {} ] :: [ {} ] :: [ Uploaded: {} | Downloaded: {} | " \
"Ratio: {} ] :: [ {} ]".format(user['Username'],
user['ClassName'],
user['DisplayStats']['Uploaded'],
user['DisplayStats']['Downloaded'],
user['DisplayStats']['Ratio'],
user['UserPage'])
connection.privmsg(event.target, msg)
+47
View File
@@ -0,0 +1,47 @@
"""
This module parses any youtube link posted in privmsg or pubmsg, responding (either in the
privmsg or to the channel the link was posted on) with the name of the video, how many views it
has gotten and then the link. Useful as most members don't like to have to click on links to
see what it actually is.
"""
import locale
import re
import requests
from hermes.module import event, rule, disabled
@disabled()
@rule(r"http(?:s?):\/\/(?:www\.)?youtu(?:be\.com\/watch\?v=|\.be\/)([\w\-\_]*)(&(amp;)?[\w\?=]*)?",
re.IGNORECASE)
@event("privmsg", "pubmsg")
def parse_youtube(bot, connection, event, match):
"""
:param bot:
:type bot: hermes.Hermes
:param connection:
:param event:
:param match:
:return:
"""
if not hasattr(bot.config, "youtube_api"):
return
if event.type == "privmsg":
target = event.source.nick
else:
target = event.target
video_id = match.group(1)
params = {'key': bot.config.youtube_api, 'id': video_id, 'maxResults': 1,
'part': 'id,snippet,statistics'}
req = requests.get("https://www.googleapis.com/youtube/v3/videos/", params)
payload = req.json()
if payload['pageInfo']['totalResults'] == 0:
return "Youtube video not found!"
video = payload['items'][0]
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)
+65
View File
@@ -0,0 +1,65 @@
"""
Stores data through reboots.
"""
import pickle
import os
import traceback
from .utils import DotDict
class PersistentStorage(object):
def __init__(self, path):
self.path = path
if os.path.isfile(path):
try:
with open(path, 'rb') as f:
self.storage = pickle.load(f)
except:
print(traceback.format_exc())
self.storage = DotDict()
else:
self.storage = DotDict()
def save(self):
try:
with open(self.path, 'wb') as f:
pickle.dump(self.storage, f)
except:
print(traceback.format_exc())
pass
def __iter__(self):
return self.storage.__iter__()
def __next__(self):
return self.storage.__next__()
def __getitem__(self, key):
return self.get(key)
def __setitem__(self, key, value):
self.store(key, value)
def __len__(self):
return len(self.storage)
def __contains__(self, key):
return key in self.storage
def store(self, key, value):
self.storage[key] = value
def get(self, key):
if key in self.storage:
return self.storage[key]
else:
return None
def clear(self, key=None):
if key:
if key in self.storage:
del self.storage[key]
else:
self.storage.clear()
+142
View File
@@ -0,0 +1,142 @@
import os
import subprocess
import yaml
def calculate_size(size):
"""
Given some integer number of bytes, converts it to the largest possible unit till the number
is somewhere between the range of [1, 1024).
:param size:
:return: a human readable string for the size
"""
units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
unit = 0
while size > 1024:
size /= 1024
unit += 1
return str(round(size, 2 if unit < 4 else 4)) + " " + units[unit]
def dict_to_list(d):
"""Converts an ordered dict into a list."""
# make sure it's a dict, that way dict_to_list can be used as an
# array_hook.
d = dict(d)
try:
return [str(d[x], 'utf-8') for x in range(len(d))]
except KeyError:
raise ValueError('dict is not a sequence')
def run_popen(command):
if isinstance(command, str):
command = command.split()
proc = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
out, err = proc.communicate()
return out, err
def get_git_hash():
"""
This function attempts to get the hash of the head commit, assuming that hermes is being
run out of a git repo, otherwise this will just return None
: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()
return git_hash
def check_pid(pid):
"""
This function checks if the given pid by sending it the 0 signal (which does nothing).
However, when this function is run on windows, it will interpret the signal as
signal.CTRL_C_EVENT which will kill the process, so we can really only use this on
Unix.
:param pid:
:return: boolean whether the process exists or not
"""
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
else:
return True
def load_config(config_file):
"""Utility function to load the configuration file for Hermes and convert all dictionaries
inside the config file to DotDict objects instead"""
config = yaml.safe_load(open(config_file))
return convert(config)
def convert(node):
"""Iterate through the node's items (if dict or list) converting them. All dicts are changed
into DotDict objects"""
if isinstance(node, dict):
for key, value in node.items():
node[key] = convert(value)
node = DotDict(node)
elif isinstance(node, list):
for elem in range(len(node)):
node[elem] = convert(node[elem])
return node
class DotDict(dict):
"""Utility class that allows for dot notation on dictionaries"""
__getattr__ = dict.get
__setattr__ = dict.__setitem__
__delattr__ = dict.__delitem__
__contains__ = dict.__contains__
def __getstate__(self):
return self.__dict__
def __setstate__(self, d):
self.__dict__.update(d)
def file_tail(file_name, num):
"""
Gets the last x number of lines from file_name. Basically the 'tail' unix command,
but in python.
:param file_name:
:param lines:
:return:
"""
"""
bufsize = 8192
fsize = os.stat(file_name).st_size
iterator = 0
with open(file_name) as f:
if bufsize > fsize:
bufsize = fsize - 1
data = []
while True:
iterator += 1
f.seek(fsize - bufsize * iterator)
data.extend(f.readlines())
if len(data) >= lines or f.tell() == 0:
return [x.strip() for x in data[-lines:]]
return []
"""
lines = []
with open(file_name) as f:
for line in f:
if len(lines) > num:
lines.pop(0)
lines.append(line)
return lines
+6
View File
@@ -0,0 +1,6 @@
irc
PyMySQL
PyYAML
requests
sqlalchemy
phpserialize
Executable
+6
View File
@@ -0,0 +1,6 @@
#! /usr/bin/env python3
from hermes import run_hermes
if __name__ == "__main__":
run_hermes()
+33
View File
@@ -0,0 +1,33 @@
from setuptools import setup
import sys
from hermes.hermes import __version__
if sys.version_info < (3, 5):
raise SystemExit("hermes can only be installed using Python 3.5+")
setup(
name='hermes',
author="itismadness",
version=__version__,
description="An IRC bot designed to be used by sites running the Gazelle tracker software.",
packages=['hermes'],
entry_points={'console_scripts': ['hermes = hermes:run_hermes']},
requires=[
'irc',
'phpserialize',
'PyMySQL',
'PyYAML',
'requests',
'sqlalchemy'
],
classifiers=[
'Development Status :: 3 - Alpha',
'Environment :: Console',
'Intended Audience :: Developers',
'License :: OSI Approved :: MIT License',
'Programming Language :: Python :: 3.5',
'Programming Language :: Python :: 3.6',
'Topic :: Communications :: Chat :: Internet Relay Chat'
]
)