refactored config

This commit is contained in:
iou1name 2019-10-08 12:39:13 -04:00
parent d06b8f2fdc
commit b552c35baa
20 changed files with 107 additions and 253 deletions

1
.gitignore vendored
View File

@ -10,3 +10,4 @@ logs/
*.txt
*.db-journal
tourettes.py
config.py

View File

@ -1,56 +1,10 @@
# Fulvia
## NIGGER DICKS 2: Electric Boogaloo
It's like Sopel, except rewritten from scratch using Twisted as a base and over half the features ripped out.
## Requirements
Python 3.6+
Python packages: `twisted python-dateutil requests bs4 wolframalpha pyenchant emoji Pillow xml2dict ipython numpy numpngw`
## Config
`nickname` - The nickname the bot will use.
`realname` - The realname the bot will use.
`username` - The user ident the bot will use.
`prefix` - The command prefix the bot will listen for.
`homedir` - The bot's home directory. Required for the bot to get certain pathing right. In the future that will be obtained automatically.
`server` - The server address to connect to.
`port` - The server port to connect to. SSL will probably not work.
`use_ssl` - Place holder.
`channels` - Which channels to join upon connection.
`db_filename` - Filename to use for the bot's database.
`owner` - The bot's owner. Use for permission purposes on restricted commands. Outranks admins. Should be the full hostmask of the user.
`admins` - Comma-delineated list of admins the bot will recognize for restricted commands. Should be the full hostmask for each one.
`default_time_format` - The format used for all timestamp operations. See the official python docs for the `time` library for more information.
`disabled_modules` - Comma-delineated list of modules *not* to load on startup. Modules should be specified without the `.py` extension.
### Example default.cfg
```
[core]
nickname = DiceBot9002
realname = DiceBot9002
username = DiceBot9002
prefix = .
homedir = /home/iou1name/fulvia
server = irc.steelbea.me
port = 6667
use_ssl = false
channels = #SomaIsGay,#test
db_filename = DiceBot9002.db
owner = iou1name!~iou1name@operational.operator
admins =
default_time_format = [%Y-%m-%d %H:%M:%S]
disabled_modules = countdown
[wolfram]
app_id = API_KEY
units = nonmetric
[movie]
tmdb_api_key = API_KEY
[currency]
api_key = API_KEY
```
## TODO
Fix the movie table
Consider re-adding the following modules: `etymology, ip`

50
bot.py
View File

@ -5,7 +5,6 @@ The core bot class for Fulvia.
import os
import sys
import time
import functools
import threading
import traceback
from datetime import datetime
@ -15,35 +14,30 @@ from twisted.words.protocols import irc
import db
import tools
import config
import loader
from trigger import Trigger
class Fulvia(irc.IRCClient):
def __init__(self, config):
self.config = config
"""The bot's config, loaded from file."""
self.nickname = config.core.nickname
self.nick = config.core.nickname
def __init__(self):
self.nickname = config.nickname
self.nick = config.nickname
"""The bot's current nickname."""
self.realname = config.core.realname
self.realname = config.realname
"""The bot's 'real name', used in whois."""
self.username = config.core.username
self.username = config.username
"""The bot's username ident used for logging into the server."""
self.host = ""
"""The bot's host, virtual or otherwise. To be filled in later."""
self.prefix = config.core.prefix
"""The command prefix the bot watches for."""
self.static = os.path.join(config.homedir, "static")
self.static = "static"
os.makedirs(self.static, exist_ok=True)
"""The path to the bot's static file directory."""
self.log_path = os.path.join(config.homedir, "logs")
self.log_path = "logs"
os.makedirs(self.static, exist_ok=True)
"""The path to the bot's log files."""
@ -68,7 +62,7 @@ class Fulvia(irc.IRCClient):
modules need it.
"""
self.db = db.FulviaDB(self.config)
self.db = db.FulviaDB()
"""
A class with some basic interactions for the bot's sqlite3 databse.
"""
@ -100,7 +94,7 @@ class Fulvia(irc.IRCClient):
self._user_joined = []
"""These get called when a user joins a channel."""
self._disabled_modules = self.config.core.disabled_modules.split(",")
self._disabled_modules = config.disabled_modules
"""These modules will NOT be loaded when load_modules() is called."""
self.load_modules()
@ -117,7 +111,7 @@ class Fulvia(irc.IRCClient):
self.url_callbacks = {}
# ensure they're empty
modules = loader.find_modules(self.config.homedir)
modules = loader.find_modules()
loaded = 0
failed = 0
for name, path in modules.items():
@ -215,7 +209,7 @@ class Fulvia(irc.IRCClient):
"""
# TODO: use time module instead of datetime
t = datetime.fromtimestamp(time.time())
timestamp = t.strftime(self.config.core.default_time_format)
timestamp = t.strftime(config.default_time_format)
self._log_dump[channel].append(timestamp + " " + text)
if time.time() - self._last_log_dump > 1:
@ -248,9 +242,9 @@ class Fulvia(irc.IRCClient):
self.log(channel, line)
funcs = []
if message.startswith(self.prefix) and message != self.prefix:
if message.startswith(config.prefix) and message != config.prefix:
command = message.partition(" ")[0]
command = command.replace(self.prefix, "", 1)
command = command.replace(config.prefix, "", 1)
cmd = self.commands.get(command)
if not cmd:
return
@ -259,7 +253,7 @@ class Fulvia(irc.IRCClient):
funcs += self._hooks
for func in funcs:
trigger = Trigger(user, channel, message, "PRIVMSG", self.config)
trigger = Trigger(user, channel, message, "PRIVMSG")
bot = FulviaWrapper(self, trigger)
if func.rate:
@ -352,8 +346,8 @@ class Fulvia(irc.IRCClient):
def signedOn(self):
"""Called when the bot successfully connects to the server."""
if self.config.core.oper_password:
self.sendLine("OPER " + self.config.core.nickname + ' ' + self.config.core.oper_password)
if config.oper_password:
self.sendLine("OPER " + config.nickname + ' ' + config.oper_password)
print(f"Signed on as {self.nickname}")
self.whois(self.nickname)
@ -361,7 +355,7 @@ class Fulvia(irc.IRCClient):
line += f"{self.nickname}!{self.username}@{self.host}"
self.log(self.hostname, line)
for channel in self.config.core.channels.split(","):
for channel in config.channels:
self.join(channel)
@ -400,7 +394,7 @@ class Fulvia(irc.IRCClient):
self.channels[channel].users[nick] = user
for func in self._user_joined:
trigger = Trigger(user, channel, f"{user} has joined", "PRIVMSG", self.config)
trigger = Trigger(user, channel, f"{user} has joined", "PRIVMSG")
bot = FulviaWrapper(self, trigger)
t = threading.Thread(target=self.call,args=(func, bot, trigger))
t.start()
@ -667,8 +661,4 @@ class FulviaWrapper():
class FulviaFactory(protocol.ReconnectingClientFactory):
# black magic going on here
protocol = property(lambda s: functools.partial(Fulvia, s.config))
def __init__(self, config):
self.config = config
protocol = Fulvia

116
config.py
View File

@ -1,116 +0,0 @@
#!/usr/bin/env python3
"""
For parsing and generating config files.
"""
from configparser import ConfigParser
class Config():
def __init__(self, filename):
"""
The bot's configuration.
The given filename will be associated with the configuration, and is
the file which will be written if write() is called. If load is not
given or True, the configuration object will load the attributes from
the file at filename.
A few default values will be set here if they are not defined in the
config file, or a config file is not loaded. They are documented below.
"""
self.filename = filename
"""The config object's associated file, as noted above."""
self.parser = ConfigParser(allow_no_value=True, interpolation=None)
self.parser.read(self.filename)
@property
def homedir(self):
"""An alias to config.core.homedir"""
# Technically it's the other way around, so we can bootstrap filename
# attributes in the core section, but whatever.
configured = None
if self.parser.has_option('core', 'homedir'):
configured = self.parser.get('core', 'homedir')
if configured:
return configured
else:
return os.path.dirname(self.filename)
def save(self):
"""Save all changes to the config file."""
with open(self.filename, 'w') as cfgfile:
self.parser.write(cfgfile)
def add_section(self, name):
"""
Add a section to the config file.
Returns ``False`` if already exists.
"""
try:
return self.parser.add_section(name)
except ConfigParser.DuplicateSectionError:
return False
def __getattr__(self, name):
"""Allows sections to be called like class attributes."""
if name in self.parser.sections():
items = self.parser.items(name)
section = ConfigSection(name, items, self) # Return a section
setattr(self, name, section)
return section
else:
raise AttributeError("%r object has no attribute %r"
% (type(self).__name__, name))
class ConfigSection(object):
"""
Represents a section of the config file.
Contains all keys in thesection as attributes.
"""
def __init__(self, name, items, parent):
object.__setattr__(self, '_name', name)
object.__setattr__(self, '_parent', parent)
for item in items:
value = item[1].strip()
if not value.lower() == 'none':
if value.lower() == 'false':
value = False
object.__setattr__(self, item[0], value)
def __getattr__(self, name):
return None
def __setattr__(self, name, value):
object.__setattr__(self, name, value)
if type(value) is list:
value = ','.join(value)
self._parent.parser.set(self._name, name, value)
def get_list(self, name):
value = getattr(self, name)
if not value:
return []
if isinstance(value, str):
value = value.split(',')
# Keep the split value, so we don't have to keep doing this
setattr(self, name, value)
return value
def readConfig(filename):
"""
Parses the provided filename and returns the config object.
"""
config = ConfigParser(allow_no_value=True, interpolation=None)
config.read(filename)
return config
def generateConfig(filename):
"""
Generates a blank config file with minimal defaults.
"""
pass

33
config.template.py Executable file
View File

@ -0,0 +1,33 @@
#!/usr/bin/env python3
"""
The bot's config file.
"""
""" Core """
nickname = 'DiceBot9002'
realname = 'DiceBot9002'
username = 'DiceBot9002'
prefix = '.'
server = 'irc.steelbea.me'
port = 6667
use_ssl = False
channels = ['#test', '#SomaIsGay']
db_filename = 'DiceBot9002.db'
owner = 'iou1name!~iou1name@operational.operator'
admins = []
default_time_format = '[%Y-%m-%d %H:%M:%S]'
disabled_modules = ['countdown']
oper_password = 'password'
""" Wolfram """
wolfram_app_id = 'password'
wolfram_units = 'nonmetric'
""" Movie """
tmdb_api_key = 'password'
""" Currency """
exchangerate_api_key = 'password'
""" Crypto """
coinlib_api_key = 'password'

8
db.py
View File

@ -6,13 +6,15 @@ import os
import sqlite3
import threading
class FulviaDB(object):
import config
class FulviaDB:
"""
Defines a basic interface and some convenience functionsfor the bot's
database.
"""
def __init__(self, config):
path = config.core.db_filename
def __init__(self):
path = config.db_filename
self.filename = path
self.db_lock = threading.Lock()

View File

@ -14,7 +14,7 @@ def load_module(bot, path):
module = importlib.import_module(path)
if hasattr(module, 'setup'):
module.setup(bot)
relevant_parts = process_module(module, bot.config)
relevant_parts = process_module(module)
for part in relevant_parts:
bot.register_callable(part)
@ -34,14 +34,13 @@ def unload_module(bot, name):
del sys.modules[name]
def find_modules(homedir):
def find_modules():
"""
Searches through homedir/modules for python files and returns a dictionary
Searches through 'modules/' for python files and returns a dictionary
with the module name as the key and the path as the value.
"""
modules_dir = os.path.join(homedir, "modules")
modules = {}
for file in os.listdir(modules_dir):
for file in os.listdir('modules'):
if not file.endswith(".py"):
continue
name = file.replace(".py", "")
@ -49,7 +48,7 @@ def find_modules(homedir):
return modules
def process_module(module, config):
def process_module(module):
"""
Takes a module object and extracts relevant data objects out of it.
Returns all callables(read: functions) and shutdowns(?).
@ -58,7 +57,7 @@ def process_module(module, config):
for key, obj in dict.items(vars(module)):
if callable(obj):
if is_triggerable(obj):
process_callable(obj, config)
process_callable(obj)
callables.append(obj)
return callables
@ -72,12 +71,10 @@ def is_triggerable(obj):
return any(hasattr(obj, attr) for attr in triggerable_attributes)
def process_callable(func, config):
def process_callable(func):
"""
Sets various helper atributes about a given function.
"""
prefix = config.core.prefix
func.thread = getattr(func, "thread", True)
func.hook = getattr(func, "hook", False)
func.rate = getattr(func, "rate", 0)

View File

@ -15,6 +15,6 @@ def BQstatus(bot, trigger):
status = "\x0304DEAD"
deathdate = "[2017-02-16 00:19:00]"
msg = "Banished Quest status: " + status + "\nTime since death: "
msg += relativeTime(bot.config, datetime.now(), deathdate) + " ago "
msg += relativeTime(datetime.now(), deathdate) + " ago "
msg += deathdate
bot.msg(msg)

View File

@ -27,6 +27,6 @@ def generic_countdown(bot, trigger):
except:
return bot.msg("Please use correct format: .countdown 2012 12 21")
msg = relativeTime(bot.config, datetime.now(), date)
msg = relativeTime(datetime.now(), date)
msg += " until " + trigger.group(2)
bot.msg(msg)

View File

@ -6,6 +6,7 @@ import re
import requests
import config
from module import commands, example, require_admin
URI = "https://coinlib.io/api/v1"
@ -19,7 +20,7 @@ def crypto(bot, trigger):
Queries coinlib.io for information about various crytocurrencies.
"""
params = {
"key": bot.config.crypto.api_key,
"key": config.coinlib_api_key,
"pref": "USD",
}
symbol = trigger.group(3)

View File

@ -33,7 +33,7 @@ def exchange(bot, trigger):
cur_to = cur_to.upper()
cur_from = cur_from.upper()
api_key = bot.config.currency.api_key
api_key = config.exchangerate_api_key
url = CUR_URI.format(**{"API_KEY": api_key, "CUR_FROM": cur_from})
res = requests.get(url, verify=True)
res.raise_for_status()

View File

@ -11,6 +11,7 @@ from sqlite3 import IntegrityError, OperationalError
import bs4
import requests
import config
from module import commands, example, require_admin
def setup(bot):
@ -50,7 +51,7 @@ def movieInfo(bot, trigger):
return bot.reply("What movie?")
word = word.replace(" ", "+")
api_key = bot.config.movie.tmdb_api_key
api_key = config.tmdb_api_key
uri = "https://api.themoviedb.org/3/search/movie?" + \
f"api_key={api_key}&query={word}"
data = requests.get(uri, timeout=30, verify=True).json()
@ -151,7 +152,7 @@ def pickMovie(bot, trigger):
bot.reply(movie[0])
if trigger.group(2) == "-m":
trigger.set_group(f".movie {movie}", bot.config)
trigger.set_group(f".movie {movie}")
movieInfo(bot, trigger)

View File

@ -10,6 +10,7 @@ import datetime
import threading
import collections
import config
from module import commands, example
class MonitorThread(threading.Thread):
@ -213,7 +214,7 @@ def create_reminder(bot, trigger, duration, message):
if duration >= 60:
remind_at = datetime.datetime.fromtimestamp(t)
t_format = bot.config.core.default_time_format
t_format = config.default_time_format
timef = datetime.datetime.strftime(remind_at, t_format)
bot.reply('Okay, will remind at %s' % timef)

View File

@ -73,7 +73,7 @@ def findandreplace(bot, trigger):
if not group:
return
g = (trigger.group(0),) + group.groups()
trigger.set_group(g, bot.config)
trigger.set_group(g)
# Correcting other person vs self.
rnick = (trigger.group(1) or trigger.nick)

View File

@ -11,6 +11,7 @@ from sqlite3 import OperationalError
from requests.structures import CaseInsensitiveDict
import config
from tools.time import relativeTime
from module import commands, example, hook, require_chanmsg, rate
@ -87,9 +88,9 @@ def seen(bot, trigger):
return bot.msg(f"I haven't seen \x0308{args.nick}")
timestamp = datetime.fromtimestamp(timestamp)
t_format = bot.config.core.default_time_format
t_format = config.default_time_format
timestamp = datetime.strftime(timestamp, t_format)
reltime = relativeTime(bot.config, datetime.now(), timestamp)
reltime = relativeTime(datetime.now(), timestamp)
if args.first:
msg = "First"

View File

@ -9,6 +9,7 @@ import threading
from datetime import datetime
from sqlite3 import OperationalError
import config
from tools.time import relativeTime
from module import commands, example, hook
@ -107,8 +108,8 @@ def tell_hook(bot, trigger):
teller, unixtime, message = tell
telldate = datetime.fromtimestamp(unixtime)
reltime = relativeTime(bot.config, datetime.now(), telldate)
t_format = bot.config.core.default_time_format
reltime = relativeTime(datetime.now(), telldate)
t_format = config.default_time_format
telldate = datetime.strftime(telldate, t_format)
msg = f"{tellee}: \x0310{message}\x03 (\x0308{teller}\x03) {telldate}" \

View File

@ -4,9 +4,9 @@ Querying Wolfram Alpha.
"""
import wolframalpha
import config
from module import commands, example
@commands('wa', 'wolfram')
@example('.wa 2+2', '[W|A] 2+2 = 4')
@example(".wa python language release date",
@ -17,11 +17,11 @@ def wa_command(bot, trigger):
"""
if not trigger.group(2):
return bot.reply("You must provide a query.")
if not bot.config.wolfram.app_id:
if not config.wolfram_app_id:
bot.reply("Wolfram|Alpha API app ID not configured.")
query = trigger.group(2).strip()
app_id = bot.config.wolfram.app_id
units = bot.config.wolfram.units
app_id = config.wolfram_app_id
units = config.wolfram_units
res = wa_query(query, app_id, units)

23
run.py
View File

@ -6,26 +6,11 @@ import os
from twisted.internet import reactor
import config
from bot import FulviaFactory
from config import Config
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(
description="Fulvia IRC Bot")
parser.add_argument(
"-c",
"--config",
help="Use a specific config file.")
args = parser.parse_args()
if not args.config:
args.config = "default.cfg"
config = Config(args.config)
server = config.core.server
port = int(config.core.port)
reactor.connectTCP(server, port, FulviaFactory(config))
server = config.server
port = config.port
reactor.connectTCP(server, port, FulviaFactory())
reactor.run()

View File

@ -6,15 +6,17 @@ from datetime import datetime
from dateutil.relativedelta import relativedelta
import config
def relativeTime(config, time_1, time_2):
def relativeTime(time_1, time_2):
"""
Returns the relative time difference between 'time_1' and 'time_2'.
If either 'time_1' or 'time_2' is a string, it will be converted to a
datetime object according to the 'default_time_format' variable in the
config.
"""
t_format = config.core.default_time_format
t_format = config.default_time_format
if type(time_1) == str:
time_1 = datetime.strptime(time_1, t_format)
if type(time_2) == str:

View File

@ -4,6 +4,8 @@ The trigger abstraction layer.
"""
import datetime
import config
def split_user(user):
"""
Splits a user hostmask into <nick>!<ident>@<host>
@ -21,14 +23,14 @@ class Group(list):
Custom list class that permits calling it like a function so as to
emulate a re.group instance.
"""
def __init__(self, message, config):
def __init__(self, message):
"""
Initializes the group class. If 'message' is a string, we split
it into groups according to the usual trigger.group structure.
Otherwise we assume it's already been split appropriately.
"""
if type(message) == str:
message = self.split_group(message, config)
message = self.split_group(message)
list.__init__(self, message)
@ -45,7 +47,7 @@ class Group(list):
return item
def split_group(self, message, config):
def split_group(self, message):
"""
Splits the message by spaces.
group(0) is always the entire message.
@ -54,11 +56,10 @@ class Group(list):
group(2) is always the entire message after the first word.
group(3+) is always every individual word after the first word.
"""
prefix = config.core.prefix
group = []
group.append(message)
words = message.split()
group.append(words[0].replace(prefix, "", 1))
group.append(words[0].replace(config.prefix, "", 1))
group.append(" ".join(words[1:]))
group += words[1:]
@ -66,7 +67,7 @@ class Group(list):
class Trigger():
def __init__(self, user, channel, message, event, config):
def __init__(self, user, channel, message, event):
self.channel = channel
"""
The channel from which the message was sent.
@ -112,7 +113,7 @@ class Trigger():
message.
"""
self.group = Group(message, config)
self.group = Group(message)
"""The ``group`` function of the ``match`` attribute.
See Python :mod:`re` documentation for details."""
@ -125,14 +126,14 @@ class Trigger():
``('#example', '-m')``
"""
admins = config.core.admins.split(",") + [config.core.owner]
admins = config.admins + [config.owner]
self.admin = any([self.compare_hostmask(admin) for admin in admins])
"""
True if the nick which triggered the command is one of the bot's
admins.
"""
self.owner = self.compare_hostmask(config.core.owner)
self.owner = self.compare_hostmask(config.owner)
"""True if the nick which triggered the command is the bot's owner."""
@ -147,9 +148,9 @@ class Trigger():
return compare_against == "@".join(self.nick, self.host)
def set_group(self, line, config):
def set_group(self, line):
"""
Allows a you to easily change the current group to a new Group
instance.
"""
self.group = Group(line, config)
self.group = Group(line)