From 538fec692deb7d6d10e1ef2afa1234ff9b0a45c6 Mon Sep 17 00:00:00 2001 From: mutantmonkey Date: Sun, 9 Apr 2017 22:50:00 -0700 Subject: [PATCH 1/4] Clean up core and add support for TLS client certs --- __init__.py | 59 ++++++++++++------ irc.py | 175 +++++++++++++++++++++++++++------------------------- phenny | 121 +++++++++++++++++++++--------------- 3 files changed, 200 insertions(+), 155 deletions(-) diff --git a/__init__.py b/__init__.py index d6a2865..deeffad 100755 --- a/__init__.py +++ b/__init__.py @@ -7,9 +7,14 @@ Licensed under the Eiffel Forum License 2. http://inamidst.com/phenny/ """ -import sys, os, time, threading, signal +import os +import signal +import sys +import threading +import time -class Watcher(object): + +class Watcher(object): # Cf. http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/496735 def __init__(self): self.child = os.fork() @@ -18,51 +23,65 @@ class Watcher(object): self.watch() def watch(self): - try: os.wait() + try: + os.wait() except KeyboardInterrupt: self.kill() sys.exit() def kill(self): - try: os.kill(self.child, signal.SIGKILL) - except OSError: pass + try: + os.kill(self.child, signal.SIGKILL) + except OSError: + pass def sig_term(self, signum, frame): self.kill() sys.exit() -def run_phenny(config): - if hasattr(config, 'delay'): - delay = config.delay - else: delay = 20 - def connect(config): +def run_phenny(config): + if hasattr(config, 'delay'): + delay = config.delay + else: + delay = 20 + + def connect(config): import bot p = bot.Phenny(config) - p.run(config.host, config.port, config.ssl, config.ipv6, - config.ca_certs) - try: Watcher() + ssl_context = p.get_ssl_context(config.ca_certs) + if config.ssl_cert and config.ssl_key: + ssl_context.load_cert_chain(config.ssl_cert, config.ssl_key) + p.run(config.host, config.port, config.ssl, config.ipv6, None, + ssl_context) + + try: + Watcher() except Exception as e: print('Warning:', e, '(in __init__.py)', file=sys.stderr) - while True: - try: connect(config) + while True: + try: + connect(config) except KeyboardInterrupt: - sys.exit() + sys.exit() if not isinstance(delay, int): break - warning = 'Warning: Disconnected. Reconnecting in %s seconds...' % delay - print(warning, file=sys.stderr) + msg = "Warning: Disconnected. Reconnecting in {0} seconds..." + print(msg.format(delay), file=sys.stderr) time.sleep(delay) -def run(config): + +def run(config): t = threading.Thread(target=run_phenny, args=(config,)) if hasattr(t, 'run'): t.run() - else: t.start() + else: + t.start() + if __name__ == '__main__': print(__doc__) diff --git a/irc.py b/irc.py index 2d06a1f..200eab5 100755 --- a/irc.py +++ b/irc.py @@ -7,30 +7,35 @@ Licensed under the Eiffel Forum License 2. http://inamidst.com/phenny/ """ -import sys, re, time, traceback -import socket, asyncore, asynchat +import asynchat +import asyncore +import re +import socket import ssl +import sys +import time -class Origin(object): +class Origin(object): source = re.compile(r'([^!]*)!?([^@]*)@?(.*)') - def __init__(self, bot, source, args): + def __init__(self, bot, source, args): if not source: source = "" match = Origin.source.match(source) self.nick, self.user, self.host = match.groups() - if len(args) > 1: + if len(args) > 1: target = args[1] - else: target = None + else: + target = None mappings = {bot.nick: self.nick, None: None} self.sender = mappings.get(target, target) -class Bot(asynchat.async_chat): - def __init__(self, nick, name, channels, password=None): +class Bot(asynchat.async_chat): + def __init__(self, nick, name, channels, password=None): asynchat.async_chat.__init__(self) self.set_terminator(b'\n') self.buffer = b'' @@ -52,97 +57,91 @@ class Bot(asynchat.async_chat): asynchat.async_chat.initiate_send(self) self.sending.release() - # def push(self, *args, **kargs): + # def push(self, *args, **kargs): # asynchat.async_chat.push(self, *args, **kargs) - def __write(self, args, text=None): + def __write(self, args, text=None): # print 'PUSH: %r %r %r' % (self, args, text) - try: - if text is not None: + try: + if text is not None: # 510 because CR and LF count too, as nyuszika7h points out self.push((b' '.join(args) + b' :' + text)[:510] + b'\r\n') else: self.push(b' '.join(args)[:512] + b'\r\n') - except IndexError: + except IndexError: pass - def write(self, args, text=None): + def write(self, args, text=None): """This is a safe version of __write""" - def safe(input): + def safe(input): if type(input) == str: input = input.replace('\n', '') input = input.replace('\r', '') return input.encode('utf-8') else: return input - try: + try: args = [safe(arg) for arg in args] - if text is not None: + if text is not None: text = safe(text) self.__write(args, text) except Exception as e: raise - #pass - def run(self, host, port=6667, ssl=False, - ipv6=False, ca_certs=None): - self.ca_certs = ca_certs - self.initiate_connect(host, port, ssl, ipv6) + def run(self, host, port=6667, ssl=False, ipv6=False, ca_certs=None, + ssl_context=None): + if ssl_context is None: + ssl_context = self.get_ssl_context(ca_certs) + self.initiate_connect(host, port, ssl, ipv6, ssl_context) - def initiate_connect(self, host, port, use_ssl, ipv6): - if self.verbose: + def get_ssl_context(self, ca_certs): + return ssl.create_default_context( + purpose=ssl.Purpose.SERVER_AUTH, + 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) if ipv6 and socket.has_ipv6: - af = socket.AF_INET6 + af = socket.AF_INET6 else: - af = socket.AF_INET - self.create_socket(af, socket.SOCK_STREAM, use_ssl, host) + 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: + try: + asyncore.loop() + except KeyboardInterrupt: sys.exit() - def create_socket(self, family, type, use_ssl=False, hostname=None): + def create_socket(self, family, type, use_ssl=False, hostname=None, + ssl_context=None): self.family_and_type = family, type sock = socket.socket(family, type) if use_ssl: - # this stuff is all new in python 3.4, so fallback if needed - try: - context = ssl.create_default_context( - purpose=ssl.Purpose.SERVER_AUTH, - cafile=self.ca_certs) - sock = context.wrap_socket(sock, server_hostname=hostname) - except: - if self.ca_certs is None: - # default to standard path on most non-EL distros - ca_certs = "/etc/ssl/certs/ca-certificates.crt" - else: - ca_certs = self.ca_certs - sock = ssl.wrap_socket(sock, ssl_version=ssl.PROTOCOL_TLSv1, - cert_reqs=ssl.CERT_REQUIRED, ca_certs=ca_certs) + sock = ssl_context.wrap_socket(sock, server_hostname=hostname) # FIXME: this doesn't work with SSL enabled #sock.setblocking(False) self.set_socket(sock) - def handle_connect(self): - if self.verbose: + def handle_connect(self): + if self.verbose: print('connected!', file=sys.stderr) - if self.password: + if self.password: self.write(('PASS', self.password)) self.write(('NICK', self.nick)) self.write(('USER', self.user, '+iw', self.nick), self.name) - def handle_close(self): + def handle_close(self): self.close() print('Closed!', file=sys.stderr) - def collect_incoming_data(self, data): + def collect_incoming_data(self, data): self.buffer += data - def found_terminator(self): + def found_terminator(self): line = self.buffer - if line.endswith(b'\r'): + if line.endswith(b'\r'): line = line[:-1] self.buffer = b'' @@ -151,35 +150,39 @@ class Bot(asynchat.async_chat): except UnicodeDecodeError: line = line.decode('iso-8859-1') - if line.startswith(':'): + if line.startswith(':'): source, line = line[1:].split(' ', 1) - else: source = None + else: + source = None - if ' :' in line: + if ' :' in line: argstr, text = line.split(' :', 1) - else: argstr, text = line, '' + else: + argstr, text = line, '' args = argstr.split() origin = Origin(self, source, args) self.dispatch(origin, tuple([text] + args)) - if args[0] == 'PING': + if args[0] == 'PING': self.write(('PONG', text)) - def dispatch(self, origin, args): + def dispatch(self, origin, args): pass - def msg(self, recipient, text): + def msg(self, recipient, text): self.sending.acquire() # Cf. http://swhack.com/logs/2006-03-01#T19-43-25 - if isinstance(text, str): - try: text = text.encode('utf-8') - except UnicodeEncodeError as e: + if isinstance(text, str): + try: + text = text.encode('utf-8') + except UnicodeEncodeError as e: text = e.__class__ + ': ' + str(e) - if isinstance(recipient, str): - try: recipient = recipient.encode('utf-8') - except UnicodeEncodeError as e: + if isinstance(recipient, str): + try: + recipient = recipient.encode('utf-8') + except UnicodeEncodeError as e: return # Split long messages @@ -197,23 +200,23 @@ class Bot(asynchat.async_chat): # No messages within the last 3 seconds? Go ahead! # Otherwise, wait so it's been at least 0.8 seconds + penalty - if self.stack: + if self.stack: elapsed = time.time() - self.stack[-1][0] - if elapsed < 3: + if elapsed < 3: penalty = float(max(0, len(text) - 50)) / 70 wait = 0.8 + penalty - if elapsed < wait: + if elapsed < wait: time.sleep(wait - elapsed) # Loop detection messages = [m[1] for m in self.stack[-8:]] - if messages.count(text) >= 5: + if messages.count(text) >= 5: text = '...' - if messages.count('...') >= 3: + if messages.count('...') >= 3: self.sending.release() return - def safe(input): + def safe(input): if type(input) == str: input = input.encode('utf-8') input = input.replace(b'\n', b'') @@ -228,43 +231,47 @@ class Bot(asynchat.async_chat): text = "\x01ACTION {0}\x01".format(text) return self.msg(recipient, text) - def notice(self, dest, text): + def notice(self, dest, text): self.write(('NOTICE', dest), text) - def error(self, origin): - try: + def error(self, origin): + try: import traceback trace = traceback.format_exc() print(trace) lines = list(reversed(trace.splitlines())) report = [lines[0].strip()] - for line in lines: + for line in lines: line = line.strip() - if line.startswith('File "/'): + if line.startswith('File "/'): report.append(line[0].lower() + line[1:]) break - else: report.append('source unknown') + else: + report.append('source unknown') self.msg(origin.sender, report[0] + ' (' + report[1] + ')') - except: self.msg(origin.sender, "Got an error.") + except: + self.msg(origin.sender, "Got an error.") -class TestBot(Bot): - def f_ping(self, origin, match, args): - delay = m.group(1) - if delay is not None: +class TestBot(Bot): + def f_ping(self, origin, match, args): + delay = match.group(1) + if delay is not None: import time time.sleep(int(delay)) self.msg(origin.sender, 'pong (%s)' % delay) - else: self.msg(origin.sender, 'pong') + else: + self.msg(origin.sender, 'pong') f_ping.rule = r'^\.ping(?:[ \t]+(\d+))?$' -def main(): +def main(): bot = TestBot('testbot007', 'testbot007', ['#wadsworth']) bot.run('irc.freenode.net') print(__doc__) -if __name__=="__main__": + +if __name__ == "__main__": main() diff --git a/phenny b/phenny index 802cd68..6411893 100755 --- a/phenny +++ b/phenny @@ -11,19 +11,23 @@ Run ./phenny, then edit ~/.phenny/default.py Then run ./phenny again """ -import sys, os, imp import argparse +import imp +import os +import sys from textwrap import dedent as trim dotdir = os.path.expanduser('~/.phenny') -def check_python_version(): - if sys.version_info < (3, 0): - error = 'Error: Requires Python 3.0 or later, from www.python.org' + +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) sys.exit(1) -def create_default_config(fn): + +def create_default_config(fn): f = open(fn, 'w') print(trim("""\ nick = 'phenny' @@ -51,7 +55,7 @@ def create_default_config(fn): # 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 @@ -59,7 +63,7 @@ def create_default_config(fn): extra = [] # Services to load: maps channel names to white or black lists - external = { + external = { '#liberal': ['!'], # allow all '#conservative': [], # allow none '*': ['!'] # default whitelist, allow all @@ -69,6 +73,7 @@ def create_default_config(fn): """), file=f) f.close() + def create_default_config_file(dotdir): print('Creating a default config file at ~/.phenny/default.py...') default = os.path.join(dotdir, 'default.py') @@ -77,10 +82,12 @@ def create_default_config_file(dotdir): print('Done; now you can edit default.py, and run phenny! Enjoy.') sys.exit(0) -def create_dotdir(dotdir): + +def create_dotdir(dotdir): print('Creating a config directory at ~/.phenny...') - try: os.mkdir(dotdir) - except Exception as e: + 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) @@ -88,87 +95,96 @@ def create_dotdir(dotdir): create_default_config_file(dotdir) -def check_dotdir(): + +def check_dotdir(): default = os.path.join(dotdir, 'default.py') - if not os.path.isdir(dotdir): + if not os.path.isdir(dotdir): create_dotdir(dotdir) - elif not os.path.isfile(default): + elif not os.path.isfile(default): create_default_config_file(dotdir) -def config_names(config): + +def config_names(config): config = config or 'default' - def files(d): + def files(d): names = os.listdir(d) return list(os.path.join(d, fn) for fn in names if fn.endswith('.py')) here = os.path.join('.', config) - if os.path.isfile(here): + if os.path.isfile(here): return [here] - if os.path.isfile(here + '.py'): + if os.path.isfile(here + '.py'): return [here + '.py'] - if os.path.isdir(here): + if os.path.isdir(here): return files(here) there = os.path.join(dotdir, config) - if os.path.isfile(there): + if os.path.isfile(there): return [there] - if os.path.isfile(there + '.py'): + if os.path.isfile(there + '.py'): return [there + '.py'] - if os.path.isdir(there): + 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) sys.exit(1) -def main(argv=None): + +def main(argv=None): # Step One: Parse The Command Line - + 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('-c', '--config', metavar='fn', + help='use this configuration file or directory') args = parser.parse_args(argv) - + # Step Two: Check Dependencies - - check_python_version() # require python2.4 or later + + check_python_version() if not args.config: - check_dotdir() # require ~/.phenny, or make it and exit - + check_dotdir() # require ~/.phenny, or make it and exit + # Step Three: Load The Configurations - + config_modules = [] - for config_name in config_names(args.config): + for config_name in config_names(args.config): name = os.path.basename(config_name).split('.')[0] + '_config' module = imp.load_source(name, config_name) module.filename = config_name - - if not hasattr(module, 'prefix'): + + if not hasattr(module, 'prefix'): module.prefix = r'\.' - - if not hasattr(module, 'name'): + + if not hasattr(module, 'name'): module.name = 'Phenny Palmersbot, http://inamidst.com/phenny/' - - if not hasattr(module, 'port'): + + if not hasattr(module, 'port'): module.port = 6667 - + if not hasattr(module, 'ssl'): module.ssl = False if not hasattr(module, 'ca_certs'): module.ca_certs = None - + + if not hasattr(module, 'ssl_cert'): + module.ssl_cert = None + + if not hasattr(module, 'ssl_key'): + module.ssl_key = None + if not hasattr(module, 'ipv6'): module.ipv6 = False - if not hasattr(module, 'password'): + if not hasattr(module, 'password'): module.password = None - if module.host == 'irc.example.net': - error = ('Error: you must edit the config file first!\n' + - "You're currently using %s" % module.filename) + 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) sys.exit(1) @@ -176,18 +192,21 @@ def main(argv=None): # Step Four: Load Phenny - try: from __init__ import run - except ImportError: - try: from phenny import run - except ImportError: + try: + from __init__ import run + except ImportError: + try: + from phenny import run + except ImportError: print("Error: Couldn't find phenny to import", file=sys.stderr) sys.exit(1) # Step Five: Initialise And Run The Phennies # @@ ignore SIGHUP - for config_module in config_modules: - run(config_module) # @@ thread this + for config_module in config_modules: + run(config_module) # @@ thread this -if __name__ == '__main__': + +if __name__ == '__main__': main() From 20cc193f469bb892694192cdb216ddcdf7372a4c Mon Sep 17 00:00:00 2001 From: mutantmonkey Date: Sun, 9 Apr 2017 23:04:05 -0700 Subject: [PATCH 2/4] Restore VTLUUG wiki tests since it's back up --- modules/test/test_vtluugwiki.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/test/test_vtluugwiki.py b/modules/test/test_vtluugwiki.py index efbac00..a1e1121 100644 --- a/modules/test/test_vtluugwiki.py +++ b/modules/test/test_vtluugwiki.py @@ -8,7 +8,7 @@ import unittest from mock import MagicMock, Mock from modules import vtluugwiki -@unittest.skip('Skipping until wiki is back up') + class TestVtluugwiki(unittest.TestCase): def setUp(self): self.phenny = MagicMock() From 999297b3167299077f237f57bfdb47c629396928 Mon Sep 17 00:00:00 2001 From: mutantmonkey Date: Sun, 9 Apr 2017 23:15:25 -0700 Subject: [PATCH 3/4] Python 3.4+ is now required --- README.md | 2 +- modules/search.py | 11 ----------- modules/test/test_search.py | 9 +-------- 3 files changed, 2 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index b5b6487..a5818a3 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ will need to be updated to run on Python3 if they do not already. All of the core modules have been ported, removed, or replaced. ## Requirements -* Python 3.2+ +* Python 3.4+ * [python-requests](http://docs.python-requests.org/en/latest/) ## Installation diff --git a/modules/search.py b/modules/search.py index 22d6188..18d13af 100644 --- a/modules/search.py +++ b/modules/search.py @@ -184,16 +184,5 @@ def search(phenny, input): phenny.reply(result) search.commands = ['search'] -def suggest(phenny, input): - if not input.group(2): - return phenny.reply("No query term.") - query = input.group(2) - uri = 'http://websitedev.de/temp-bin/suggest.pl?q=' - answer = web.get(uri + web.quote(query).replace('+', '%2B')) - if answer: - phenny.say(answer) - else: phenny.reply('Sorry, no result.') -suggest.commands = ['suggest'] - if __name__ == '__main__': print(__doc__.strip()) diff --git a/modules/test/test_search.py b/modules/test/test_search.py index 10281ba..bd13cd0 100644 --- a/modules/test/test_search.py +++ b/modules/test/test_search.py @@ -8,7 +8,7 @@ import unittest from mock import MagicMock, Mock from modules.search import duck_api, google_search, google_count, \ formatnumber, g, gc, gcs, bing_search, bing, duck_search, duck, \ - search, suggest + search class TestSearch(unittest.TestCase): @@ -75,10 +75,3 @@ class TestSearch(unittest.TestCase): duck(self.phenny, input) assert self.phenny.reply.called is True - - def test_suggest(self): - input = Mock(group=lambda x: 'vtluug') - suggest(self.phenny, input) - - assert (self.phenny.reply.called is True or \ - self.phenny.say.called is True) From 42325d85e8783b458850bb4e0a7ee07cbcd1ff37 Mon Sep 17 00:00:00 2001 From: mutantmonkey Date: Sun, 9 Apr 2017 23:20:49 -0700 Subject: [PATCH 4/4] update travis python versions --- .travis.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index e001881..3222263 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,10 +2,9 @@ language: python sudo: false cache: pip python: -- 3.2 -- 3.3 - 3.4 - 3.5 +- 3.6 install: - pip install -r requirements.txt script: nosetests