From e315862740c8d010c4f2117f5aa9cb668d37871a Mon Sep 17 00:00:00 2001 From: Robin Richtsfeld Date: Tue, 28 Aug 2018 14:10:58 +0200 Subject: [PATCH 1/8] Simplify database management --- modules/clock.py | 1 - modules/head.py | 1 - modules/logger.py | 33 ++++++++---------- modules/remind.py | 63 ++++++++++++++-------------------- modules/test/test_remind.py | 4 +-- modules/weather.py | 2 +- tools.py | 68 +++++++++++++++++++++++++++++++++++++ 7 files changed, 110 insertions(+), 62 deletions(-) diff --git a/modules/clock.py b/modules/clock.py index d7597637f..e3acfd70e 100644 --- a/modules/clock.py +++ b/modules/clock.py @@ -16,7 +16,6 @@ import datetime import web from decimal import Decimal as dec -from tools import deprecated TimeZones = {'KST': 9, 'CADT': 10.5, 'EETDST': 3, 'MESZ': 2, 'WADT': 9, 'EET': 2, 'MST': -7, 'WAST': 8, 'IST': 5.5, 'B': 2, diff --git a/modules/head.py b/modules/head.py index 330ebda33..5ba367b87 100644 --- a/modules/head.py +++ b/modules/head.py @@ -12,7 +12,6 @@ import time from html.entities import name2codepoint import web -from tools import deprecated def head(phenny, input): diff --git a/modules/logger.py b/modules/logger.py index 88e4c6dbb..aee6b6d47 100644 --- a/modules/logger.py +++ b/modules/logger.py @@ -4,17 +4,17 @@ author: mutantmonkey """ -import os import random import sqlite3 +from tools import DatabaseCursor, db_path def setup(self): - fn = self.nick + '-' + self.config.host + '.logger.db' - self.logger_db = os.path.join(os.path.expanduser('~/.phenny'), fn) - self.logger_conn = sqlite3.connect(self.logger_db) + self.logger_db = db_path(self, 'logger') - c = self.logger_conn.cursor() - c.execute('''create table if not exists lines_by_nick ( + connection = sqlite3.connect(self.logger_db) + cursor = connection.cursor() + + cursor.execute('''create table if not exists lines_by_nick ( channel varchar(255), nick varchar(255), lines unsigned big int not null default 0, @@ -24,10 +24,10 @@ def setup(self): unique (channel, nick) on conflict replace );''') -def logger(phenny, input): - if not logger.conn: - logger.conn = sqlite3.connect(phenny.logger_db) + cursor.close() + connection.close() +def logger(phenny, input): sqlite_data = { 'channel': input.sender, 'nick': input.nick, @@ -39,8 +39,8 @@ def logger(phenny, input): if sqlite_data['msg'][:8] == '\x01ACTION ': sqlite_data['msg'] = '* {0} {1}'.format(sqlite_data['nick'], sqlite_data['msg'][8:-1]) - c = logger.conn.cursor() - c.execute('''insert or replace into lines_by_nick + with DatabaseCursor(phenny.logger_db) as cursor: + cursor.execute('''insert or replace into lines_by_nick (channel, nick, lines, characters, last_time, quote) values( :channel, @@ -53,15 +53,10 @@ def logger(phenny, input): coalesce((select quote from lines_by_nick where channel=:channel and nick=:nick), :msg) );''', sqlite_data) - c.close() - - if random.randint(0, 20) == 10: - c = logger.conn.cursor() - c.execute('update lines_by_nick set quote=:msg where channel=:channel \ - and nick=:nick', sqlite_data) - c.close() - logger.conn.commit() + if random.randint(0, 20) == 10: + cursor.execute('update lines_by_nick set quote=:msg where channel=:channel \ + and nick=:nick', sqlite_data) logger.conn = None logger.priority = 'low' logger.rule = r'(.*)' diff --git a/modules/remind.py b/modules/remind.py index df39ec640..dc3c17fd1 100644 --- a/modules/remind.py +++ b/modules/remind.py @@ -7,51 +7,38 @@ http://inamidst.com/phenny/ """ -import os, re, time, threading - -def filename(self): - name = self.nick + '-' + self.config.host + '.reminders.db' - return os.path.join(os.path.expanduser('~/.phenny'), name) - -def load_database(name): - data = {} - if os.path.isfile(name): - f = open(name, 'r') - for line in f: - unixtime, channel, nick, message = line.split('\t') - message = message.rstrip('\n') - t = int(unixtime) - reminder = (channel, nick, message) - try: data[t].append(reminder) - except KeyError: data[t] = [reminder] - f.close() - return data - -def dump_database(name, data): - f = open(name, 'w') - for unixtime, reminders in data.items(): - for channel, nick, message in reminders: - f.write('%s\t%s\t%s\t%s\n' % (unixtime, channel, nick, message)) - f.close() +import re +import threading +import time +from modules import clock +from tools import GrumbleError, read_db, write_db + +def load_database(phenny): + try: + return read_db(phenny, 'reminders') + except GrumbleError: + return {} + +def dump_database(phenny): + write_db(phenny, 'reminders', phenny.remind_data) def setup(phenny): - phenny.rfn = filename(phenny) - phenny.rdb = load_database(phenny.rfn) + phenny.remind_data = load_database(phenny) def monitor(phenny): time.sleep(5) while True: now = int(time.time()) - unixtimes = [int(key) for key in phenny.rdb] + unixtimes = [int(key) for key in phenny.remind_data] oldtimes = [t for t in unixtimes if t <= now] if oldtimes: for oldtime in oldtimes: - for (channel, nick, message) in phenny.rdb[oldtime]: + for (channel, nick, message) in phenny.remind_data[oldtime]: if message: phenny.msg(channel, nick + ': ' + message) else: phenny.msg(channel, nick + '!') - del phenny.rdb[oldtime] - dump_database(phenny.rfn, phenny.rdb) + del phenny.remind_data[oldtime] + dump_database(phenny) time.sleep(2.5) targs = (phenny,) @@ -120,10 +107,10 @@ def remind(phenny, input): t = int(time.time()) + duration reminder = (input.sender, input.nick, message) - try: phenny.rdb[t].append(reminder) - except KeyError: phenny.rdb[t] = [reminder] + try: phenny.remind_data[t].append(reminder) + except KeyError: phenny.remind_data[t] = [reminder] - dump_database(phenny.rfn, phenny.rdb) + dump_database(phenny) if duration >= 60: w = '' @@ -177,11 +164,11 @@ def at(phenny, input): reminder = (input.sender, input.nick, message) # phenny.say(str((d, reminder))) - try: phenny.rdb[d].append(reminder) - except KeyError: phenny.rdb[d] = [reminder] + try: phenny.remind_data[d].append(reminder) + except KeyError: phenny.remind_data[d] = [reminder] phenny.sending.acquire() - dump_database(phenny.rfn, phenny.rdb) + dump_database(phenny) phenny.sending.release() phenny.reply("Reminding at %s %s - in %s minute(s)" % (t, z, duration)) diff --git a/modules/test/test_remind.py b/modules/test/test_remind.py index dca7e6cc2..b9f58c0a0 100644 --- a/modules/test/test_remind.py +++ b/modules/test/test_remind.py @@ -18,8 +18,8 @@ def setUp(self): self.phenny.nick = 'phenny' self.phenny.config.host = 'test-phenny.example.com' - remind.load_database = lambda name: {} - remind.dump_database = lambda name, data: name + remind.load_database = lambda phenny: {} + remind.dump_database = lambda phenny: None remind.setup(self.phenny) def test_remind(self): diff --git a/modules/weather.py b/modules/weather.py index f6a489d37..182dffc2f 100644 --- a/modules/weather.py +++ b/modules/weather.py @@ -11,7 +11,7 @@ import metar import json import web -from tools import deprecated, GrumbleError +from tools import GrumbleError r_from = re.compile(r'(?i)([+-]\d+):00 from') diff --git a/tools.py b/tools.py index 62421682a..0583d0cdc 100755 --- a/tools.py +++ b/tools.py @@ -7,6 +7,74 @@ http://inamidst.com/phenny/ """ +import os +import sqlite3 +import pickle +from time import time + + +# e.g. make read/write from disk no-ops +debug = False + +dotdir = os.path.expanduser('~/.phenny') + +def dot_path(filename): + path = os.path.join(dotdir, filename) + dirname = os.path.dirname(path) + os.makedirs(dirname, exist_ok=True) + return path + +def write_obj(path, data): + if debug: + return + + with open(path, 'wb') as f: + pickle.dump(data, f, pickle.HIGHEST_PROTOCOL) + +def read_obj(path, warn_after=None): + if debug: + raise GrumbleError() + + try: + last_changed = os.path.getmtime(path) + except FileNotFoundError as e: + raise GrumbleError() from e + + if warn_after and (time() - last_changed) > warn_after: + raise ResourceWarning('Database out of date') + + try: + with open(path, 'rb') as f: + return pickle.load(f) + # Pickling may throw anything + except Exception as e: + raise GrumbleError() from e + +def db_path(self, name): + return dot_path('%s-%s.%s.db' % (self.nick, self.config.host, name)) + +def write_db(self, name, data, **kwargs): + write_obj(db_path(self, name), data, **kwargs) + +def read_db(self, name, **kwargs): + return read_obj(db_path(self, name), **kwargs) + +class DatabaseCursor(): + def __init__(self, path): + self.path = path + + def __enter__(self): + self.connection = sqlite3.connect( + self.path, + detect_types=sqlite3.PARSE_DECLTYPES, + isolation_level=None + ) + self.cursor = self.connection.cursor() + return self.cursor + + def __exit__(self, *args): + self.cursor.close() + self.connection.close() def decorate(obj, delegate): class Decorator(object): From d07d773ecaf8c82a93eb6958403bb05f303e063b Mon Sep 17 00:00:00 2001 From: Robin Richtsfeld Date: Tue, 28 Aug 2018 14:21:41 +0200 Subject: [PATCH 2/8] Pass IRC message args in proper order --- bot.py | 48 +++++++++++++++++++++++++----------------------- irc.py | 16 ++++++++++------ test/test_bot.py | 6 +++--- test/test_irc.py | 4 ++-- 4 files changed, 40 insertions(+), 34 deletions(-) diff --git a/bot.py b/bot.py index 4925acaf8..49c44fca1 100755 --- a/bot.py +++ b/bot.py @@ -175,14 +175,14 @@ def __getattr__(self, attr): return PhennyWrapper(self) - def input(self, origin, text, bytes, match, event, args): + def input(self, origin, text, match, event, args): class CommandInput(str): - def __new__(cls, text, origin, bytes, match, event, args): + def __new__(cls, text, origin, match, event, args): s = str.__new__(cls, text) s.sender = decode(origin.sender) s.nick = decode(origin.nick) s.event = event - s.bytes = bytes + s.bytes = text s.match = match s.group = match.group s.groups = match.groups @@ -191,7 +191,7 @@ def __new__(cls, text, origin, bytes, match, event, args): s.owner = s.nick == self.config.owner return s - return CommandInput(text, origin, bytes, match, event, args) + return CommandInput(text, origin, match, event, args) def call(self, func, origin, phenny, input): try: func(phenny, input) @@ -208,36 +208,38 @@ def limit(self, origin, func): return True return False - def dispatch(self, origin, args): - bytes, event, args = args[0], args[1], args[2:] - text = decode(bytes) - event = decode(event) + def dispatch(self, origin, args, text): + event = args[0] if origin.nick in self.config.ignore: return - for priority in ('high', 'medium', 'low'): + for priority in ('high', 'medium', 'low'): items = list(self.commands[priority].items()) - for regexp, funcs in items: - for func in funcs: + + for regexp, funcs in items: + for func in funcs: if event != func.event and func.event != '*': continue match = regexp.match(text) - if match: - if self.limit(origin, func): continue + if not match: continue + + if self.limit(origin, func): continue - phenny = self.wrapped(origin, text, match) - input = self.input(origin, text, bytes, match, event, args) + phenny = self.wrapped(origin, text, match) + input = self.input(origin, text, match, event, args) - if func.thread: - targs = (func, origin, phenny, input) - t = threading.Thread(target=self.call, args=targs) - t.start() - else: self.call(func, origin, phenny, input) + if func.thread: + targs = (func, origin, phenny, input) + t = threading.Thread(target=self.call, args=targs) + t.start() + else: + self.call(func, origin, phenny, input) - for source in [decode(origin.sender), decode(origin.nick)]: - try: self.stats[(func.name, source)] += 1 - except KeyError: + for source in [decode(origin.sender), decode(origin.nick)]: + try: + self.stats[(func.name, source)] += 1 + except KeyError: self.stats[(func.name, source)] = 1 if __name__ == '__main__': diff --git a/irc.py b/irc.py index a95926f98..8a6eede17 100755 --- a/irc.py +++ b/irc.py @@ -164,18 +164,22 @@ def found_terminator(self): source = None if ' :' in line: - argstr, text = line.split(' :', 1) + middle, trailing = line.split(' :', 1) + middle = middle.split() + args = tuple(middle + [trailing]) + text = trailing else: - argstr, text = line, '' - args = argstr.split() + middle, trailing = line.split(), None + args = tuple(middle) + text = '' origin = Origin(self, source, args) - self.dispatch(origin, tuple([text] + args)) + self.dispatch(origin, args, text) if args[0] == 'PING': - self.proto.pong(text) + self.proto.pong(args[-1]) - def dispatch(self, origin, args): + def dispatch(self, origin, args, text): pass def msg(self, recipient, text): diff --git a/test/test_bot.py b/test/test_bot.py index 3b67dbef4..79098ff03 100644 --- a/test/test_bot.py +++ b/test/test_bot.py @@ -35,7 +35,7 @@ class MockOrigin(object): match = Mock() event = "PRIVMSG" args = ('#phenny', ) - cmdinput = self.bot.input(origin, text, text, match, event, args) + cmdinput = self.bot.input(origin, text, match, event, args) self.assertEqual(cmdinput.sender, origin.sender) self.assertEqual(cmdinput.nick, origin.nick) @@ -58,7 +58,7 @@ class MockOrigin(object): match = Mock() event = "PRIVMSG" args = ('#phenny', ) - cmdinput = self.bot.input(origin, text, text, match, event, args) + cmdinput = self.bot.input(origin, text, match, event, args) self.assertEqual(cmdinput.owner, True) @@ -72,6 +72,6 @@ class MockOrigin(object): match = Mock() event = "PRIVMSG" args = ('#phenny', ) - cmdinput = self.bot.input(origin, text, text, match, event, args) + cmdinput = self.bot.input(origin, text, match, event, args) self.assertEqual(cmdinput.admin, True) diff --git a/test/test_irc.py b/test/test_irc.py index 7299b2cc2..320fd8b11 100644 --- a/test/test_irc.py +++ b/test/test_irc.py @@ -47,10 +47,10 @@ def test_login(self, mock_write): @patch('irc.Bot.write') def test_ping(self, mock_write): - self.bot.buffer = b"PING" + self.bot.buffer = b"PING example.org" self.bot.found_terminator() - mock_write.assert_called_once_with(('PONG', ''), None) + mock_write.assert_called_once_with(('PONG', 'example.org'), None) @patch('irc.Bot.push') def test_msg(self, mock_push): From 73c8abd4f23deec5013396982f896b18ff3fc0fc Mon Sep 17 00:00:00 2001 From: Robin Richtsfeld Date: Tue, 28 Aug 2018 14:27:22 +0200 Subject: [PATCH 3/8] Make PhennyWrapper more robust --- bot.py | 41 +++++++++++++++++++---------------------- modules/head.py | 6 +++--- modules/search.py | 18 +++++++++--------- modules/seen.py | 4 ++-- 4 files changed, 33 insertions(+), 36 deletions(-) diff --git a/bot.py b/bot.py index 49c44fca1..2cddd8905 100755 --- a/bot.py +++ b/bot.py @@ -7,9 +7,13 @@ http://inamidst.com/phenny/ """ -import sys, os, re, threading, imp +import sys +import os +import re +import threading +import imp import irc -import tools +from tools import GrumbleError, decorate home = os.getcwd() @@ -158,22 +162,14 @@ def sub(pattern, self=self): bind(self, func.priority, regexp, func) def wrapped(self, origin, text, match): - class PhennyWrapper(object): - def __init__(self, phenny): - self.bot = phenny - - def __getattr__(self, attr): - sender = origin.sender or text - if attr == 'reply': - return (lambda msg: - self.bot.msg(sender, origin.nick + ': ' + msg)) - elif attr == 'say': - return lambda msg: self.bot.msg(sender, msg) - elif attr == 'do': - return lambda msg: self.bot.action(sender, msg) - return getattr(self.bot, attr) - - return PhennyWrapper(self) + sender = origin.sender or text + delegate = { + 'reply': lambda msg: self.msg(sender, origin.nick + ': ' + msg), + 'say': lambda msg: self.msg(sender, msg), + 'do': lambda msg: self.action(sender, msg), + } + + return decorate(self, delegate) def input(self, origin, text, match, event, args): class CommandInput(str): @@ -193,11 +189,12 @@ def __new__(cls, text, origin, match, event, args): return CommandInput(text, origin, match, event, args) - def call(self, func, origin, phenny, input): - try: func(phenny, input) - except tools.GrumbleError as e: + def call(self, func, origin, phenny, input): + try: + func(phenny, input) + except GrumbleError as e: self.msg(origin.sender, str(e)) - except Exception as e: + except Exception as e: self.error(origin) def limit(self, origin, func): diff --git a/modules/head.py b/modules/head.py index 5ba367b87..c7e5e7af7 100644 --- a/modules/head.py +++ b/modules/head.py @@ -78,9 +78,9 @@ def head(phenny, input): def noteuri(phenny, input): uri = input.group(1) - if not hasattr(phenny.bot, 'last_seen_uri'): - phenny.bot.last_seen_uri = {} - phenny.bot.last_seen_uri[input.sender] = uri + if not hasattr(phenny, 'last_seen_uri'): + phenny.last_seen_uri = {} + phenny.last_seen_uri[input.sender] = uri noteuri.rule = r'.*(http[s]?://[^<> "\x01]+)[,.]?' noteuri.priority = 'low' diff --git a/modules/search.py b/modules/search.py index 43b3ad236..951265f39 100644 --- a/modules/search.py +++ b/modules/search.py @@ -52,9 +52,9 @@ def g(phenny, input): uri = google_search(query) if uri: phenny.reply(uri) - if not hasattr(phenny.bot, 'last_seen_uri'): - phenny.bot.last_seen_uri = {} - phenny.bot.last_seen_uri[input.sender] = uri + if not hasattr(phenny, 'last_seen_uri'): + phenny.last_seen_uri = {} + phenny.last_seen_uri[input.sender] = uri else: phenny.reply("No results found for '%s'." % query) g.commands = ['g'] g.priority = 'high' @@ -117,9 +117,9 @@ def bing(phenny, input): uri = bing_search(query, lang) if uri: phenny.reply(uri) - if not hasattr(phenny.bot, 'last_seen_uri'): - phenny.bot.last_seen_uri = {} - phenny.bot.last_seen_uri[input.sender] = uri + if not hasattr(phenny, 'last_seen_uri'): + phenny.last_seen_uri = {} + phenny.last_seen_uri[input.sender] = uri else: phenny.reply("No results found for '%s'." % query) bing.commands = ['bing'] bing.example = '.bing swhack' @@ -155,9 +155,9 @@ def duck(phenny, input): uri = duck_search(query) if uri: phenny.reply(uri) - if not hasattr(phenny.bot, 'last_seen_uri'): - phenny.bot.last_seen_uri = {} - phenny.bot.last_seen_uri[input.sender] = uri + if not hasattr(phenny, 'last_seen_uri'): + phenny.last_seen_uri = {} + phenny.last_seen_uri[input.sender] = uri else: phenny.reply("No results found for '%s'." % query) duck.commands = ['duck', 'ddg'] duck.example = '.duck football' diff --git a/modules/seen.py b/modules/seen.py index e369dc5d8..33ecdf3eb 100644 --- a/modules/seen.py +++ b/modules/seen.py @@ -30,10 +30,10 @@ def f_seen(phenny, input): @deprecated def f_note(self, origin, match, args): def note(self, origin, match, args): - if not hasattr(self.bot, 'seen'): + if not hasattr(self, 'seen'): fn = self.nick + '-' + self.config.host + '.seen' path = os.path.join(os.path.expanduser('~/.phenny'), fn) - self.bot.seen = shelve.open(path) + self.seen = shelve.open(path) if origin.sender.startswith('#'): self.seen[origin.nick.lower()] = (origin.sender, time.time()) self.seen.sync() From cf19d973b38ee9040920b51086d5afb81e006cb4 Mon Sep 17 00:00:00 2001 From: Robin Richtsfeld Date: Tue, 28 Aug 2018 14:47:29 +0200 Subject: [PATCH 4/8] Overhaul logging --- __init__.py | 8 +- bot.py | 180 ++++++++++++++++++++++++-------------------- irc.py | 17 +++-- modules/botsnack.py | 15 ++-- modules/reload.py | 2 +- modules/seen.py | 14 +++- modules/startup.py | 19 +++-- phenny | 35 +++++---- 8 files changed, 170 insertions(+), 120 deletions(-) diff --git a/__init__.py b/__init__.py index deeffadae..7e6a4f2ba 100755 --- a/__init__.py +++ b/__init__.py @@ -7,12 +7,15 @@ http://inamidst.com/phenny/ """ +import logging import os import signal import sys import threading import time +logger = logging.getLogger('phenny') + class Watcher(object): # Cf. http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/496735 @@ -59,7 +62,7 @@ def connect(config): try: Watcher() except Exception as e: - print('Warning:', e, '(in __init__.py)', file=sys.stderr) + logger.warning(str(e) + ' (in __init__.py)') while True: try: @@ -70,8 +73,7 @@ def connect(config): if not isinstance(delay, int): break - msg = "Warning: Disconnected. Reconnecting in {0} seconds..." - print(msg.format(delay), file=sys.stderr) + logger.warning("Disconnected. Reconnecting in {0} seconds...".format(delay)) time.sleep(delay) diff --git a/bot.py b/bot.py index 2cddd8905..45c496e85 100755 --- a/bot.py +++ b/bot.py @@ -12,8 +12,12 @@ import re import threading import imp +import traceback import irc from tools import GrumbleError, decorate +import logging + +logger = logging.getLogger('phenny') home = os.getcwd() @@ -66,100 +70,114 @@ def setup(self): for filename in filenames: name = os.path.basename(filename)[:-3] if name in excluded_modules: continue - # if name in sys.modules: - # del sys.modules[name] - try: module = imp.load_source(name, filename) - except Exception as e: - print("Error loading %s: %s (in bot.py)" % (name, e), file=sys.stderr) - else: - if hasattr(module, 'setup'): - module.setup(self) - self.register(vars(module)) - modules.append(name) + + try: + module = SourceFileLoader(name, filename).load_module() + except Exception as e: + trace = traceback.format_exc() + logger.error("Error loading %s module:\n%s" % (name, trace)) + continue + + if module_control(self, module, 'setup'): + self.register(module) + modules[name] = module + + self.modules = modules if modules: - print('Registered modules:', ', '.join(modules), file=sys.stderr) - else: print("Warning: Couldn't find any modules", file=sys.stderr) + logger.info('Registered modules: ' + ', '.join(modules)) + else: + logger.warning("Couldn't find any modules") self.bind_commands() - def register(self, variables): + def register(self, module): # This is used by reload.py, hence it being methodised - for name, obj in variables.items(): - if hasattr(obj, 'commands') or hasattr(obj, 'rule'): - self.variables[name] = obj + self.variables[module.__name__] = {} - def bind_commands(self): - self.commands = {'high': {}, 'medium': {}, 'low': {}} - - def bind(self, priority, regexp, func): - print(priority, regexp.pattern.encode('utf-8'), func) - # register documentation - if not hasattr(func, 'name'): - func.name = func.__name__ - if func.__doc__: - if hasattr(func, 'example'): - example = func.example - example = example.replace('$nickname', self.nick) - else: example = None - self.doc[func.name] = (func.__doc__, example) - self.commands[priority].setdefault(regexp, []).append(func) + for name, obj in vars(module).items(): + if hasattr(obj, 'commands') or hasattr(obj, 'rule'): + self.variables[module.__name__][name] = obj + + def bind(self, module, name, func, regexp): + # register documentation + if not hasattr(func, 'name'): + func.name = func.__name__ + + if func.__doc__: + if hasattr(func, 'example'): + example = func.example + example = example.replace('$nickname', self.nick) + else: example = None + + self.doc[func.name] = (func.__doc__, example) + + self.commands[func.priority].setdefault(regexp, []).append(func) + + def bind_command(self, module, name, func): + logger.debug("Binding module '{:}' command '{:}'".format(module, name)) + + if not hasattr(func, 'priority'): + func.priority = 'medium' + + if not hasattr(func, 'thread'): + func.thread = True + + if not hasattr(func, 'event'): + func.event = 'PRIVMSG' + else: + func.event = func.event.upper() def sub(pattern, self=self): # These replacements have significant order pattern = pattern.replace('$nickname', re.escape(self.nick)) return pattern.replace('$nick', r'%s[,:] +' % re.escape(self.nick)) - for name, func in self.variables.items(): - # print name, func - if not hasattr(func, 'priority'): - func.priority = 'medium' - - if not hasattr(func, 'thread'): - func.thread = True - - if not hasattr(func, 'event'): - func.event = 'PRIVMSG' - else: func.event = func.event.upper() - - if hasattr(func, 'rule'): - if isinstance(func.rule, str): - pattern = sub(func.rule) - regexp = re.compile(pattern) - bind(self, func.priority, regexp, func) - - if isinstance(func.rule, tuple): - # 1) e.g. ('$nick', '(.*)') - if len(func.rule) == 2 and isinstance(func.rule[0], str): - prefix, pattern = func.rule - prefix = sub(prefix) - regexp = re.compile(prefix + pattern) - bind(self, func.priority, regexp, func) - - # 2) e.g. (['p', 'q'], '(.*)') - elif len(func.rule) == 2 and isinstance(func.rule[0], list): - prefix = self.config.prefix - commands, pattern = func.rule - for command in commands: - command = r'(%s)\b(?: +(?:%s))?' % (command, pattern) - regexp = re.compile(prefix + command) - bind(self, func.priority, regexp, func) - - # 3) e.g. ('$nick', ['p', 'q'], '(.*)') - elif len(func.rule) == 3: - prefix, commands, pattern = func.rule - prefix = sub(prefix) - for command in commands: - command = r'(%s) +' % command - regexp = re.compile(prefix + command + pattern) - bind(self, func.priority, regexp, func) - - if hasattr(func, 'commands'): - for command in func.commands: - template = r'^%s(%s)(?: +(.*))?$' - pattern = template % (self.config.prefix, command) - regexp = re.compile(pattern) - bind(self, func.priority, regexp, func) + if hasattr(func, 'rule'): + if isinstance(func.rule, str): + pattern = sub(func.rule) + regexp = re.compile(pattern) + self.bind(module, name, func, regexp) + + if isinstance(func.rule, tuple): + # 1) e.g. ('$nick', '(.*)') + if len(func.rule) == 2 and isinstance(func.rule[0], str): + prefix, pattern = func.rule + prefix = sub(prefix) + regexp = re.compile(prefix + pattern) + self.bind(module, name, func, regexp) + + # 2) e.g. (['p', 'q'], '(.*)') + elif len(func.rule) == 2 and isinstance(func.rule[0], list): + prefix = self.config.prefix + commands, pattern = func.rule + for command in commands: + command = r'(%s)\b(?: +(?:%s))?' % (command, pattern) + regexp = re.compile(prefix + command) + self.bind(module, name, func, regexp) + + # 3) e.g. ('$nick', ['p', 'q'], '(.*)') + elif len(func.rule) == 3: + prefix, commands, pattern = func.rule + prefix = sub(prefix) + for command in commands: + command = r'(%s) +' % command + regexp = re.compile(prefix + command + pattern) + self.bind(module, name, func, regexp) + + if hasattr(func, 'commands'): + for command in func.commands: + template = r'^%s(%s)(?: +(.*))?$' + pattern = template % (self.config.prefix, command) + regexp = re.compile(pattern) + self.bind(module, name, func, regexp) + + def bind_commands(self): + self.commands = {'high': {}, 'medium': {}, 'low': {}} + + for module, functions in self.variables.items(): + for name, func in functions.items(): + self.bind_command(module, name, func) def wrapped(self, origin, text, match): sender = origin.sender or text diff --git a/irc.py b/irc.py index 8a6eede17..233fa413b 100755 --- a/irc.py +++ b/irc.py @@ -15,11 +15,14 @@ import socket import ssl import sys +import logging import time import traceback import threading from tools import decorate +logger = logging.getLogger('phenny') + class Origin(object): source = re.compile(r'([^!]*)!?([^@]*)@?(.*)') @@ -106,15 +109,16 @@ def get_ssl_context(self, ca_certs): cafile=ca_certs) def initiate_connect(self, host, port, use_ssl, ipv6, ssl_context): - if self.verbose: - message = 'Connecting to %s:%s...' % (host, port) - print(message, end=' ', file=sys.stderr) + logger.info('Connecting to %s:%s...' % (host, port)) + if ipv6 and socket.has_ipv6: af = socket.AF_INET6 else: af = socket.AF_INET + self.create_socket(af, socket.SOCK_STREAM, use_ssl, host, ssl_context) self.connect((host, port)) + try: asyncore.loop() except KeyboardInterrupt: @@ -131,8 +135,7 @@ def create_socket(self, family, type, use_ssl=False, hostname=None, self.set_socket(sock) def handle_connect(self): - if self.verbose: - print('connected!', file=sys.stderr) + logger.info('connected!') if self.password: self.proto.pass_(self.password) @@ -142,7 +145,7 @@ def handle_connect(self): def handle_close(self): self.close() - print('Closed!', file=sys.stderr) + logger.info('Closed!') def collect_incoming_data(self, data): self.buffer += data @@ -228,7 +231,7 @@ def action(self, recipient, text): def error(self, origin): try: trace = traceback.format_exc() - print(trace) + logger.error(str(trace)) lines = list(reversed(trace.splitlines())) report = [lines[0].strip()] diff --git a/modules/botsnack.py b/modules/botsnack.py index 485eabeb3..4b4d7af97 100644 --- a/modules/botsnack.py +++ b/modules/botsnack.py @@ -12,7 +12,12 @@ cooldown period all calls to botsnack are ignored. """ -import random, math, time +import logging +import math +import random +import time + +logger = logging.getLogger('phenny') # the rate that affects how much eating a snack nourishes the bot # smaller number = less nourishment = more snacks can be eaten (before fullness) @@ -45,12 +50,12 @@ def botsnack(phenny, input): # ignore this invocation. Else reset to the default state if botsnack.coolingdown: if now - botsnack.coolingstarted > botsnack.coolingperiod: - print("cooling down over, reseting") + logging.debug("cooling down over, reseting") botsnack.coolingdown = False botsnack.hunger = 50.0 botsnack.last_tick = now else: - print("cooling down! %s < %s" %(now - botsnack.coolingstarted, botsnack.coolingperiod)) + logging.debug("cooling down! %s < %s" %(now - botsnack.coolingstarted, botsnack.coolingperiod)) return # ignore! # 1. Time has has passed, so the bot has gotten @@ -61,7 +66,7 @@ def botsnack(phenny, input): botsnack.hunger = increase_hunger(old_hunger, delta) - print("hunger was %s, increased to %s" %(old_hunger, botsnack.hunger)) + logging.debug("hunger was %s, increased to %s" %(old_hunger, botsnack.hunger)) botsnack.last_tick = now @@ -69,7 +74,7 @@ def botsnack(phenny, input): old_hunger = botsnack.hunger botsnack.hunger = decrease_hunger(old_hunger, random.uniform(1,5)) - print("hunger was %s, decreased to %s" %(old_hunger, botsnack.hunger)) + logging.debug("hunger was %s, decreased to %s" %(old_hunger, botsnack.hunger)) if botsnack.hunger > 95: # special case to prevent abuse phenny.say("Too much food!") diff --git a/modules/reload.py b/modules/reload.py index 0723d61be..1d5ef6517 100644 --- a/modules/reload.py +++ b/modules/reload.py @@ -42,7 +42,7 @@ def f_reload(phenny, input): mtime = os.path.getmtime(module.__file__) modified = time.strftime('%Y-%m-%d %H:%M:%S', time.gmtime(mtime)) - phenny.register(vars(module)) + phenny.register(module) phenny.bind_commands() phenny.reply('%r (version: %s)' % (module, modified)) diff --git a/modules/seen.py b/modules/seen.py index 33ecdf3eb..49db26277 100644 --- a/modules/seen.py +++ b/modules/seen.py @@ -7,9 +7,15 @@ http://inamidst.com/phenny/ """ -import time, os, shelve, datetime +import datetime +import logging +import os +import shelve +import time from tools import deprecated +logger = logging.getLogger('phenny') + def f_seen(phenny, input): """.seen - Reports when was last seen.""" nick = input.group(2).lower() @@ -38,8 +44,10 @@ def note(self, origin, match, args): self.seen[origin.nick.lower()] = (origin.sender, time.time()) self.seen.sync() - try: note(self, origin, match, args) - except Exception as e: print(e) + try: + note(self, origin, match, args) + except Exception as error: + logger.error(str(error)) f_note.rule = r'(.*)' f_note.priority = 'low' diff --git a/modules/startup.py b/modules/startup.py index a77a967fb..b9604bf4f 100644 --- a/modules/startup.py +++ b/modules/startup.py @@ -7,10 +7,14 @@ http://inamidst.com/phenny/ """ -import threading, time +import logging +import threading +import time + +logger = logging.getLogger('phenny') def setup(phenny): - print("Setting up phenny") + logger.info("Setting up phenny") # by clsn phenny.data = {} refresh_delay = 300.0 @@ -20,7 +24,7 @@ def setup(phenny): except: pass def close(): - print("Nobody PONGed our PING, restarting") + logger.info("Nobody PONGed our PING, restarting") phenny.handle_close() def pingloop(): @@ -39,11 +43,13 @@ def pong(phenny, input): pong.event = 'PONG' pong.thread = True pong.rule = r'.*' - phenny.variables['pong'] = pong -def startup(phenny, input): - import time + if 'startup' in phenny.variables: + phenny.variables['startup']['pong'] = pong + else: + phenny.variables['startup'] = {'pong': pong} +def startup(phenny, input): # Start the ping loop. Has to be done after USER on e.g. quakenet if phenny.data.get('startup.setup.pingloop'): phenny.data['startup.setup.pingloop']() @@ -58,6 +64,7 @@ def startup(phenny, input): # Cf. http://swhack.com/logs/2005-12-05#T19-32-36 for channel in phenny.channels: phenny.proto.join(channel) + logger.info(channel) time.sleep(0.5) startup.rule = r'(.*)' startup.event = '251' diff --git a/phenny b/phenny index 358d7fde0..d82b5a6d0 100755 --- a/phenny +++ b/phenny @@ -12,18 +12,21 @@ Then run ./phenny again """ import argparse +import logging import os import sys from importlib.machinery import SourceFileLoader from textwrap import dedent as trim +logging.basicConfig(format='%(levelname)s: %(message)s') +logger = logging.getLogger('phenny') + dotdir = os.path.expanduser('~/.phenny') def check_python_version(): if sys.version_info < (3, 4): - error = 'Error: Requires Python 3.4 or later, from www.python.org' - print(error, file=sys.stderr) + logger.critical('Requires Python 3.4 or later, from www.python.org') sys.exit(1) @@ -75,22 +78,23 @@ def create_default_config(fn): def create_default_config_file(dotdir): - print('Creating a default config file at ~/.phenny/default.py...') + logger.info('Creating a default config file at ~/.phenny/default.py...') default = os.path.join(dotdir, 'default.py') create_default_config(default) - print('Done; now you can edit default.py, and run phenny! Enjoy.') + logger.info('Done; now you can edit default.py, and run phenny! Enjoy.') sys.exit(0) def create_dotdir(dotdir): - print('Creating a config directory at ~/.phenny...') + logger.info('Creating a config directory at ~/.phenny...') + try: os.mkdir(dotdir) except Exception as e: - print('There was a problem creating %s:' % dotdir, file=sys.stderr) - print(e.__class__, str(e), file=sys.stderr) - print('Please fix this and then run phenny again.', file=sys.stderr) + logger.critical('There was a problem creating %s:' % dotdir) + logger.critical(e.__class__ + ' ' + str(e)) + logger.critical('Please fix this and then run phenny again.') sys.exit(1) create_default_config_file(dotdir) @@ -128,8 +132,8 @@ def config_names(config): if os.path.isdir(there): return files(there) - print("Error: Couldn't find a config file!", file=sys.stderr) - print('What happened to ~/.phenny/default.py?', file=sys.stderr) + logger.critical("Couldn't find a config file!") + logger.critical('What happened to ~/.phenny/default.py?') sys.exit(1) @@ -139,8 +143,12 @@ def main(argv=None): parser = argparse.ArgumentParser(description="A Python IRC bot.") parser.add_argument('-c', '--config', metavar='fn', help='use this configuration file or directory') + parser.add_argument('-v', '--verbose', + action='store_true', help='enable verbose logging') args = parser.parse_args(argv) + logger.setLevel(logging.DEBUG if args.verbose else logging.INFO) + # Step Two: Check Dependencies check_python_version() @@ -172,9 +180,8 @@ def main(argv=None): setattr(module, key, value) if module.host == 'irc.example.net': - error = ('Error: you must edit the config file first!\n' + - "You're currently using %s" % module.filename) - print(error, file=sys.stderr) + logger.critical('You must edit the config file first!') + logger.critical("You're currently using %s" % module.filename) sys.exit(1) config_modules.append(module) @@ -187,7 +194,7 @@ def main(argv=None): try: from phenny import run except ImportError: - print("Error: Couldn't find phenny to import", file=sys.stderr) + logger.critical("Couldn't find phenny to import") sys.exit(1) # Step Five: Initialise And Run The Phennies From ad0eeb609ce09a936715002a4cda8e0ac120db28 Mon Sep 17 00:00:00 2001 From: Robin Richtsfeld Date: Tue, 28 Aug 2018 14:49:54 +0200 Subject: [PATCH 5/8] Move default.py into its own file --- default.py.example | 38 +++++++++++++++++++++++++++++++++++ phenny | 50 +++++----------------------------------------- 2 files changed, 43 insertions(+), 45 deletions(-) create mode 100644 default.py.example diff --git a/default.py.example b/default.py.example new file mode 100644 index 000000000..ef47a0a05 --- /dev/null +++ b/default.py.example @@ -0,0 +1,38 @@ +nick = 'phenny' +host = 'irc.example.net' +port = 6667 +ssl = False +ipv6 = False +channels = ['#example', '#test'] +owner = 'yournickname' + +# password is the NickServ password, serverpass is the server password +# password = 'example' +# serverpass = 'serverpass' + +# linx-enabled features (.linx, .lnx) +# leave the api key blank to not use them and be sure to add the 'linx' module to the ignore list. +linx_api_key = "" + +# These are people who will be able to use admin.py's functions... +admins = [owner, 'someoneyoutrust'] +# But admin.py is disabled by default, as follows: +exclude = ['admin', 'linx', 'foodforus'] + +ignore = [''] + +# If you want to enumerate a list of modules rather than disabling +# some, use "enable = ['example']", which takes precedent over exclude +# +# enable = [] + +# Directories to load user modules from +# e.g. /path/to/my/modules +extra = [] + +# Services to load: maps channel names to white or black lists +external = { + '#liberal': ['!'], # allow all + '#conservative': [], # allow none + '*': ['!'] # default whitelist, allow all +} diff --git a/phenny b/phenny index d82b5a6d0..7eed1eba1 100755 --- a/phenny +++ b/phenny @@ -30,51 +30,11 @@ def check_python_version(): sys.exit(1) -def create_default_config(fn): - f = open(fn, 'w') - print(trim("""\ - nick = 'phenny' - host = 'irc.example.net' - port = 6667 - ssl = False - ipv6 = False - channels = ['#example', '#test'] - owner = 'yournickname' - - # password is the NickServ password, serverpass is the server password - # password = 'example' - # serverpass = 'serverpass' - - # linx-enabled features (.linx, .lnx) - # leave the api key blank to not use them and be sure to add the 'linx' module to the ignore list. - linx_api_key = "" - - # These are people who will be able to use admin.py's functions... - admins = [owner, 'someoneyoutrust'] - # But admin.py is disabled by default, as follows: - exclude = ['admin', 'linx', 'foodforus'] - - ignore = [''] - - # If you want to enumerate a list of modules rather than disabling - # some, use "enable = ['example']", which takes precedent over exclude - # - # enable = [] - - # Directories to load user modules from - # e.g. /path/to/my/modules - extra = [] - - # Services to load: maps channel names to white or black lists - external = { - '#liberal': ['!'], # allow all - '#conservative': [], # allow none - '*': ['!'] # default whitelist, allow all - } - - # EOF - """), file=f) - f.close() +def create_default_config(path): + source = os.path.join(os.path.dirname(__file__), 'default.py.example') + + with open(source, 'r') as src, open(path, 'w') as dst: + dst.write(src.read()) def create_default_config_file(dotdir): From 5f4003f2afcc7994aa6ba9ee1b56bb19d5724e6c Mon Sep 17 00:00:00 2001 From: Robin Richtsfeld Date: Tue, 28 Aug 2018 15:01:30 +0200 Subject: [PATCH 6/8] Refactor module control --- bot.py | 23 ++++++++++++++++++++--- modules/reload.py | 17 ++++++++++------- 2 files changed, 30 insertions(+), 10 deletions(-) diff --git a/bot.py b/bot.py index 45c496e85..4238727dd 100755 --- a/bot.py +++ b/bot.py @@ -35,6 +35,22 @@ def decode(bytes): return bytes return text +def module_control(phenny, module, func): + if not hasattr(module, func): + return True + + try: + getattr(module, func)(phenny) + return True + except GrumbleError as e: + desc = str(e) + except Exception as e: + desc = traceback.format_exc() + + name = os.path.basename(module.__file__) + logger.error("Error during %s of %s module:\n%s" % (func, name, desc)) + return False + class Phenny(irc.Bot): def __init__(self, config): args = (config.nick, config.name, config.channels, config.password) @@ -65,8 +81,9 @@ def setup(self): if n.endswith('.py') and not n.startswith('_'): filenames.append(os.path.join(fn, n)) - modules = [] + modules = {} excluded_modules = getattr(self.config, 'exclude', []) + for filename in filenames: name = os.path.basename(filename)[:-3] if name in excluded_modules: continue @@ -84,8 +101,8 @@ def setup(self): self.modules = modules - if modules: - logger.info('Registered modules: ' + ', '.join(modules)) + if modules: + logger.info('Registered modules: ' + ', '.join(sorted(modules.keys()))) else: logger.warning("Couldn't find any modules") diff --git a/modules/reload.py b/modules/reload.py index 1d5ef6517..684a8239c 100644 --- a/modules/reload.py +++ b/modules/reload.py @@ -7,8 +7,10 @@ http://inamidst.com/phenny/ """ -import sys, os.path, time, imp -import irc +import imp +import os +import time +from bot import module_control def f_reload(phenny, input): """Reloads a module, for use by admins only.""" @@ -24,20 +26,21 @@ def f_reload(phenny, input): phenny.setup() return phenny.reply('done') - if name not in sys.modules: + if name not in phenny.modules: return phenny.reply('%s: no such module!' % name) + module = phenny.modules[name] # Thanks to moot for prodding me on this - path = sys.modules[name].__file__ + path = module.__file__ if path.endswith('.pyc') or path.endswith('.pyo'): path = path[:-1] if not os.path.isfile(path): return phenny.reply('Found %s, but not the source file' % name) + module_control(phenny, module, 'teardown') module = imp.load_source(name, path) - sys.modules[name] = module - if hasattr(module, 'setup'): - module.setup(phenny) + phenny.modules[name] = module + module_control(phenny, module, 'setup') mtime = os.path.getmtime(module.__file__) modified = time.strftime('%Y-%m-%d %H:%M:%S', time.gmtime(mtime)) From ac26a450fca5f1aca99e6e79ef46f6c5e633d270 Mon Sep 17 00:00:00 2001 From: Robin Richtsfeld Date: Tue, 28 Aug 2018 15:03:19 +0200 Subject: [PATCH 7/8] Add restart capability --- modules/reload.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/modules/reload.py b/modules/reload.py index 684a8239c..08ee9bd97 100644 --- a/modules/reload.py +++ b/modules/reload.py @@ -12,22 +12,24 @@ import time from bot import module_control +def restart(phenny): + for module in phenny.modules: + module_control(phenny, module, 'teardown') + + os.execv('phenny', sys.argv) + def f_reload(phenny, input): """Reloads a module, for use by admins only.""" - if not input.admin: return + if not (input.admin or input.owner): return name = input.group(2) - if name == phenny.config.owner: - return phenny.reply('What?') if (not name) or (name == '*'): - phenny.variables = None - phenny.commands = None - phenny.setup() - return phenny.reply('done') + restart(phenny) + return if name not in phenny.modules: - return phenny.reply('%s: no such module!' % name) + return phenny.reply("No '%s' module loaded" % name) module = phenny.modules[name] # Thanks to moot for prodding me on this From d02691452f2320930dca4e94f7aa35f27214d100 Mon Sep 17 00:00:00 2001 From: Robin Richtsfeld Date: Mon, 8 Oct 2018 22:12:06 +0200 Subject: [PATCH 8/8] Replace deprecated imp module with importlib --- bot.py | 2 +- modules/reload.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bot.py b/bot.py index 4238727dd..5bb1eae45 100755 --- a/bot.py +++ b/bot.py @@ -11,9 +11,9 @@ import os import re import threading -import imp import traceback import irc +from importlib.machinery import SourceFileLoader from tools import GrumbleError, decorate import logging diff --git a/modules/reload.py b/modules/reload.py index 08ee9bd97..78b183372 100644 --- a/modules/reload.py +++ b/modules/reload.py @@ -7,9 +7,9 @@ http://inamidst.com/phenny/ """ -import imp import os import time +from importlib.machinery import SourceFileLoader from bot import module_control def restart(phenny): @@ -40,7 +40,7 @@ def f_reload(phenny, input): return phenny.reply('Found %s, but not the source file' % name) module_control(phenny, module, 'teardown') - module = imp.load_source(name, path) + module = SourceFileLoader(name, path).load_module() phenny.modules[name] = module module_control(phenny, module, 'setup')