diff --git a/bin/pyircbot b/bin/pyircbot index 4f3aab4..12cc955 100755 --- a/bin/pyircbot +++ b/bin/pyircbot @@ -1,9 +1,11 @@ #!/usr/bin/env python3 import sys import logging +import signal from argparse import ArgumentParser from pyircbot import PyIRCBot + if __name__ == "__main__": " logging level and facility " logging.basicConfig(level=logging.INFO, @@ -30,7 +32,12 @@ if __name__ == "__main__": log.debug(botconfig) bot = PyIRCBot(botconfig) - try: - bot.loop() - except KeyboardInterrupt: - bot.kill(message="Ctrl-C pressed!") + + def signal_handler(signum, stack): + print('Received:', signum) + bot.kill(message="received signal {}".format(signum)) + + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) + + bot.run() diff --git a/docs/api/._.DS_Store b/docs/api/._.DS_Store deleted file mode 100644 index 40565d6..0000000 Binary files a/docs/api/._.DS_Store and /dev/null differ diff --git a/docs/setup/initial_config.rst b/docs/setup/initial_config.rst index 8644450..9330208 100644 --- a/docs/setup/initial_config.rst +++ b/docs/setup/initial_config.rst @@ -32,14 +32,13 @@ Instance Configuration }, "connection":{ "servers": [ - "weber.freenode.net", - "asimov.freenode.net", - "card.freenode.net", - "dickson.freenode.net", - "morgan.freenode.net" + ["weber.freenode.net", 6667], + ["asimov.freenode.net", 6667], + ["card.freenode.net", 6667], + ["dickson.freenode.net", 6667], + ["morgan.freenode.net", 6667] ], - "ipv6":"off", - "port":6667 + "ipv6":"off" }, "modules":[ "PingResponder", @@ -47,16 +46,16 @@ Instance Configuration ] } -In the example directory, this is stored in `config.json`. This may be +In the example directory, this is stored in `config.json`. This may be substituted for a YML file with the same data structure. This contains several options: .. cmdoption:: bot.datadir - Location where module data will be stored. This directory must contains - two directories: `config` and `data`. Config contains a config file for - each module of the same name (for example: Services.json for ``Services`` - module). Data can be empty, the bot will create directories for each + Location where module data will be stored. This directory must contains + two directories: `config` and `data`. Config contains a config file for + each module of the same name (for example: Services.json for ``Services`` + module). Data can be empty, the bot will create directories for each module as needed. .. cmdoption:: bot.rpcbind @@ -74,18 +73,15 @@ options: .. cmdoption:: connection.servers - List of hostnames or IP addresses of the IRC server to connection to. First - entry will be used for the initial connection on startup. If we the bot - must reconnect to the IRC server later, the next server will be used. + List of hostnames or IP addresses and ports of the IRC server to connection + to. First entry will be used for the initial connection on startup. If we + the bot must reconnect to the IRC server later, the next server will + be used. .. cmdoption:: connection.ipv6 Enable or disable defaulting to IPv6 using the value "off" or "on" -.. cmdoption:: connection.port - - Port to connect to on the IRC server - .. cmdoption:: modules A list of modules to load. Modules are loaded in the order they are listed diff --git a/examples/config.json b/examples/config.json index 3581b94..b2119d4 100644 --- a/examples/config.json +++ b/examples/config.json @@ -7,18 +7,17 @@ }, "connection":{ "servers": [ - "weber.freenode.net", - "asimov.freenode.net", - "card.freenode.net", - "dickson.freenode.net", - "morgan.freenode.net" + ["weber.freenode.net", 6667], + ["asimov.freenode.net", 6667], + ["card.freenode.net", 6667], + ["dickson.freenode.net", 6667], + ["morgan.freenode.net", 6667] ], "ipv6":"off", - "port":6667 }, "modules":[ "PingResponder", "Services", "UserModule" ] -} \ No newline at end of file +} diff --git a/pyircbot/irccore.py b/pyircbot/irccore.py index 3672345..902afba 100644 --- a/pyircbot/irccore.py +++ b/pyircbot/irccore.py @@ -6,34 +6,24 @@ """ -import queue import socket -import asynchat -import asyncore +import asyncio import logging import traceback import sys from inspect import getargspec -from socket import SHUT_RDWR -from threading import Thread -from time import sleep, time +from time import sleep from collections import namedtuple +from io import StringIO -try: - from cStringIO import StringIO -except: - from io import BytesIO as StringIO - - -IRCEvent = namedtuple("IRCEvent", "args prefix trailing") +IRCEvent = namedtuple("IRCEvent", "command args prefix trailing") UserPrefix = namedtuple("UserPrefix", "nick username hostname") ServerPrefix = namedtuple("ServerPrefix", "hostname") -class IRCCore(asynchat.async_chat): +class IRCCore(object): - def __init__(self): - asynchat.async_chat.__init__(self) + def __init__(self, servers): self.connected = False """If we're connected or not""" @@ -49,155 +39,61 @@ class IRCCore(asynchat.async_chat): self.server = 0 """Current server index""" - self.servers = [] + self.servers = servers """List of server address""" self.port = 0 """Server port""" self.ipv6 = False """Use IPv6?""" - self.OUTPUT_BUFFER_SIZE = 1000 - self.SEND_WAIT = 0.800 - self.outputQueue = queue.Queue(self.OUTPUT_BUFFER_SIZE) - self.outputQueueRunner = OutputQueueRunner(self) - self.outputQueueRunner.start() - - # IRC Messages are terminated with \r\n - self.set_terminator(b"\r\n") - # Set up hooks for modules self.initHooks() - # Map for asynchat - self.asynmap = {} - - def loop(self): + async def loop(self, loop): while self.alive: try: - asyncore.loop(map=self.asynmap, timeout=1) - except Exception: + # TODO support ipv6 again + self.reader, self.writer = await asyncio.open_connection(self.servers[self.server][0], + port=self.servers[self.server][1], + loop=loop, + ssl=None) + self.fire_hook("_CONNECT") + except (socket.gaierror, ConnectionRefusedError): + traceback.print_exc() + logging.warning("Non-fatal connect error, trying next server...") + self.server = (self.server + 1) % len(self.servers) + await asyncio.sleep(1, loop=loop) + continue - self.log.error("Loop error: ") - self.log.error(IRCCore.trace()) + while self.alive: + try: + data = await self.reader.readuntil() + self.log.debug("<<< {}".format(repr(data))) + self.process_line(data.decode("UTF-8")) + except (ConnectionResetError, asyncio.streams.IncompleteReadError): + traceback.print_exc() + break + self.fire_hook("_DISCONNECT") + self.writer.close() + if self.alive: + # TODO ramp down reconnect attempts + logging.info("Reconnecting in 3s...") + sleep(3) - # Remove from asynmap - for key in list(self.asynmap.keys())[:]: - del self.asynmap[key] - - if self.alive: - logging.info("Loop: reconnecting") - try: - self._connect() - except Exception: - self.log.error("Error reconnecting: ") - self.log.error(IRCCore.trace()) - - def kill(self, message="Help! Another thread is killing me :(", alive=False): + async def kill(self, message="Help! Another thread is killing me :("): """Send quit message, flush queue, and close the socket - :param message: Quit message + :param message: Quit message to send before disconnecting :type message: str - :param alive: True causes a reconnect after disconnecting - :type alive: bool """ - # Pauses output queue - self.outputQueueRunner.paused = not alive - # Clear any pending messages - self.outputQueueRunner.clear() - # Send quit message and flush queue + self.alive = False self.act_QUIT(message) # TODO will this hang if the socket is having issues? - self.outputQueueRunner.flush() - # Signal disconnection - self.alive = alive - # Close socket - self.socket.shutdown(SHUT_RDWR) - self.close() + await self.writer.drain() + self.writer.close() self.log.info("Kill complete") - " Net related code here on down " - - def getBuf(self): - """Return the network buffer and clear it""" - self.buffer.seek(0) - data = self.buffer.read() - self.buffer = StringIO() - return data - - def collect_incoming_data(self, data): - """Recieve data from the IRC server, append it to the buffer - - :param data: the data that was recieved - :type data: str""" - - self.buffer.write(data) - - def found_terminator(self): - """A complete command was pushed through, so clear the buffer and process it.""" - line = None - buf = self.getBuf() - try: - line = buf.decode("UTF-8") - except UnicodeDecodeError as ude: - self.log.error("found_terminator(): could not decode input as UTF-8") - self.log.error("found_terminator(): data: %s" % line) - self.log.error("found_terminator(): repr(data): %s" % repr(line)) - self.log.error("found_terminator(): error: %s" % str(ude)) - return - self.log.debug("< {}".format(line)) - self.process_data(line) - - def handle_close(self): - """Called when the socket is disconnected. Triggers the _DISCONNECT hook""" - self.log.info("handle_close") - self.connected = False - self.close() - self.fire_hook("_DISCONNECT") - - def handle_error(self, *args, **kwargs): - """Called on fatal network errors.""" - self.log.error("Connection failed (handle_error)") - self.log.error(str(args)) - self.log.error(str(kwargs)) - self.log.error(IRCCore.trace()) - - def _connect(self): - """Connect to IRC""" - self.server += 1 - if self.server >= len(self.servers): - self.server = 0 - serverHostname = self.servers[self.server] - self.log.info("Connecting to %(server)s:%(port)i", {"server": serverHostname, "port": self.port}) - socket_type = socket.AF_INET - if self.ipv6: - self.log.info("IPv6 is enabled.") - socket_type = socket.AF_INET6 - socketInfo = socket.getaddrinfo(serverHostname, self.port, socket_type) - self.create_socket(socket_type, socket.SOCK_STREAM) - self.log.info("Socket created: %s" % self.socket.fileno()) - self.connect(socketInfo[0][4]) - self.log.info("Connection established") - self._fileno = self.socket.fileno() - # See http://willpython.blogspot.com/2010/08/multiple-event-loops-with-asyncore-and.html - self.asynmap[self._fileno] = self - self.log.info("_connect: Socket map: %s" % str(self.asynmap)) - - def handle_connect(self): - """When asynchat indicates our socket is connected, fire the _CONNECT hook""" - self.connected = True - self.log.info("handle_connect: connected") - self.fire_hook("_CONNECT") - self.log.info("handle_connect: complete") - - def sendRaw(self, text, prio=2): - """Queue messages (raw string) to be sent to the IRC server - - :param text: the string to send - :type text: str""" - text = (text + "\r\n").encode("UTF-8").decode().encode("UTF-8") - self.outputQueue.put((prio, text), block=False) - - def process_data(self, data): - """Process one line of tet irc sent us + def process_line(self, data): + """Process one line of text irc sent us :param data: the data to process :type data: str""" @@ -223,6 +119,7 @@ class IRCCore(asynchat.async_chat): args = data.split(" ") for index, arg in enumerate(args): args[index] = arg.strip() + self.fire_hook("_RECV", args=args, prefix=prefix, trailing=trailing) if command not in self.hookcalls: self.log.warning("Unknown command: cmd='%s' prefix='%s' args='%s' trailing='%s'" % (command, prefix, args, @@ -230,44 +127,50 @@ class IRCCore(asynchat.async_chat): else: self.fire_hook(command, args=args, prefix=prefix, trailing=trailing) + def sendRaw(self, data): + self.log.warning(">>> {}".format(repr(data))) + self.writer.write((data + "\r\n").encode("UTF-8")) + + + " Module related code " def initHooks(self): """Defines hooks that modules can listen for events of""" self.hooks = [ - '_CONNECT', # Called when the bot connects to IRC on the socket level - '_DISCONNECT', # Called when the irc socket is forcibly closed - '_RECV', # Called on network activity - 'NOTICE', # :irc.129irc.com NOTICE AUTH :*** Looking up your hostname... - 'MODE', # :CloneABCD MODE CloneABCD :+iwx - 'PING', # PING :irc.129irc.com - 'JOIN', # :CloneA!dave@hidden-B4F6B1AA.rit.edu JOIN :#clonea - 'QUIT', # :HCSMPBot!~HCSMPBot@108.170.48.18 QUIT :Quit: Disconnecting! - 'NICK', # :foxiAway!foxi@irc.hcsmp.com NICK :foxi - 'PART', # :CloneA!dave@hidden-B4F6B1AA.rit.edu PART #clonea - 'PRIVMSG', # :CloneA!dave@hidden-B4F6B1AA.rit.edu PRIVMSG #clonea :aaa - 'KICK', # :xMopxShell!~rduser@host KICK #xMopx2 xBotxShellTest :xBotxShellTest - 'INVITE', # :gmx!~gmxgeek@irc.hcsmp.com INVITE Tyrone :#hcsmp' - '001', # :irc.129irc.com 001 CloneABCD :Welcome to the 129irc IRC Network CloneABCD!CloneABCD@djptwc-laptop1.rit.edu - '002', # :irc.129irc.com 002 CloneABCD :Your host is irc.129irc.com, running version Unreal3.2.8.1 - '003', # :irc.129irc.com 003 CloneABCD :This server was created Mon Jul 19 2010 at 03:12:01 EDT - '004', # :irc.129irc.com 004 CloneABCD irc.129irc.com Unreal3.2.8.1 iowghraAsORTVSxNCWqBzvdHtGp lvhopsmntikrRcaqOALQbSeIKVfMCuzNTGj - '005', # :irc.129irc.com 005 CloneABCD CMDS=KNOCK,MAP,DCCALLOW,USERIP UHNAMES NAMESX SAFELIST HCN MAXCHANNELS=10 CHANLIMIT=#:10 MAXLIST=b:60,e:60,I:60 NICKLEN=30 CHANNELLEN=32 TOPICLEN=307 KICKLEN=307 AWAYLEN=307 :are supported by this server - '250', # :chaos.esper.net 250 xBotxShellTest :Highest connection count: 1633 (1632 clients) (186588 connections received) - '251', # :irc.129irc.com 251 CloneABCD :There are 1 users and 48 invisible on 2 servers - '252', # :irc.129irc.com 252 CloneABCD 9 :operator(s) online - '254', # :irc.129irc.com 254 CloneABCD 6 :channels formed - '255', # :irc.129irc.com 255 CloneABCD :I have 42 clients and 1 servers - '265', # :irc.129irc.com 265 CloneABCD :Current Local Users: 42 Max: 47 - '266', # :irc.129irc.com 266 CloneABCD :Current Global Users: 49 Max: 53 - '332', # :chaos.esper.net 332 xBotxShellTest #xMopx2 :/ #XMOPX2 / https://code.google.com/p/pyircbot/ (Channel Topic) - '333', # :chaos.esper.net 333 xBotxShellTest #xMopx2 xMopxShell!~rduser@108.170.60.242 1344370109 - '353', # :irc.129irc.com 353 CloneABCD = #clonea :CloneABCD CloneABC - '366', # :irc.129irc.com 366 CloneABCD #clonea :End of /NAMES list. - '372', # :chaos.esper.net 372 xBotxShell :motd text here - '375', # :chaos.esper.net 375 xBotxShellTest :- chaos.esper.net Message of the Day - - '376', # :chaos.esper.net 376 xBotxShell :End of /MOTD command. - '422', # :irc.129irc.com 422 CloneABCD :MOTD File is missing - '433', # :nova.esper.net 433 * pyircbot3 :Nickname is already in use. + '_CONNECT', + '_DISCONNECT', + '_RECV', + 'NOTICE', + 'MODE', + 'PING', + 'JOIN', + 'QUIT', + 'NICK', + 'PART', + 'PRIVMSG', + 'KICK', + 'INVITE', + '001', + '002', + '003', + '004', + '005', + '250', + '251', + '252', + '254', + '255', + '265', + '266', + '332', + '333', + '353', + '366', + '372', + '375', + '376', + '422', + '433', ] " mapping of hooks to methods " self.hookcalls = {command: [] for command in self.hooks} @@ -283,11 +186,10 @@ class IRCCore(asynchat.async_chat): :type prefix: str :param trailing: data payload of the command :type trailing: str""" - for hook in self.hookcalls[command]: try: if len(getargspec(hook).args) == 2: - hook(IRCCore.packetAsObject(args, prefix, trailing)) + hook(IRCCore.packetAsObject(command, args, prefix, trailing)) else: hook(args, prefix, trailing) @@ -324,7 +226,7 @@ class IRCCore(asynchat.async_chat): self.log.warning("Invalid hook - %s" % command) return False - def packetAsObject(args, prefix, trailing): + def packetAsObject(command, args, prefix, trailing): """Given an irc message's args, prefix, and trailing data return an object with these properties :param args: list of args from the IRC packet @@ -335,7 +237,7 @@ class IRCCore(asynchat.async_chat): :type trailing: str :returns: object -- a IRCEvent object with the ``args``, ``prefix``, ``trailing``""" - return IRCEvent(args, + return IRCEvent(command, args, IRCCore.decodePrefix(prefix) if prefix else None, trailing) @@ -467,67 +369,10 @@ class IRCCore(asynchat.async_chat): :param message: quit message :type message: str""" - self.sendRaw("QUIT :%s" % message, prio=0) + self.sendRaw("QUIT :%s" % message) def act_PASS(self, password): """ Send server password, for use on connection """ - self.sendRaw("PASS %s" % password, prio=0) - - -class OutputQueueRunner(Thread): - """Rate-limited output queue""" - def __init__(self, bot): - Thread.__init__(self, daemon=True) - self.bot = bot # reference to main bot thread - self.log = logging.getLogger('OutputQueueRunner') - self.paused = False - - def run(self): - """Constantly sends messages unless bot is disconnecting/ed""" - lastSend = time() - while True: - # Rate limit - sinceLast = time() - lastSend - if sinceLast < self.bot.SEND_WAIT: - toSleep = self.bot.SEND_WAIT - sinceLast - sleep(toSleep) - - # Pop item and execute - if self.bot.connected and not self.paused: - try: - self.process_queue_item() - lastSend = time() - except queue.Empty: - # self.log.debug("Queue is empty") - pass - sleep(0.01) - - def process_queue_item(self): - """Remove 1 item from queue and process it""" - prio, text = self.bot.outputQueue.get(block=True, timeout=10) - # self.log.debug("%s>> %s" % (prio,text)) - self.bot.outputQueue.task_done() - self.log.debug("> {}".format(text.decode('UTF-8'))) - self.bot.send(text) - - def clear(self): - """Discard all items from queue""" - length = self.bot.outputQueue.qsize() - try: - while True: - self.bot.outputQueue.get(block=False) - except queue.Empty: - pass - # self.log.debug("output queue cleared") - return length - - def flush(self): - """Process all items in queue""" - for i in range(0, self.bot.outputQueue.qsize()): - try: - self.process_queue_item() - except: - pass - # self.log.debug("output queue flushed") + self.sendRaw("PASS %s" % password) diff --git a/pyircbot/pyircbot.py b/pyircbot/pyircbot.py index d73cb3b..2bd708b 100644 --- a/pyircbot/pyircbot.py +++ b/pyircbot/pyircbot.py @@ -7,28 +7,29 @@ """ import logging -import time import sys -import traceback from pyircbot.rpc import BotRPC from pyircbot.irccore import IRCCore from collections import namedtuple import os.path +import asyncio + ParsedCommand = namedtuple("ParsedCommand", "command args args_str message") + class PyIRCBot(object): """:param botconfig: The configuration of this instance of the bot. Passed by main.py. :type botconfig: dict """ - version = "4.0.0-r03" + version = "4.1.0" def __init__(self, botconfig): self.log = logging.getLogger('PyIRCBot') """Reference to logger object""" - self.botconfig = botconfig """saved copy of the instance config""" + self.botconfig = botconfig """storage of imported modules""" self.modules = {} @@ -36,16 +37,11 @@ class PyIRCBot(object): """instances of modules""" self.moduleInstances = {} - self.rpc = BotRPC(self) """Reference to BotRPC thread""" + self.rpc = BotRPC(self) - self.irc = IRCCore() - """IRC protocol class""" - self.irc.servers = self.botconfig["connection"]["servers"] - self.irc.port = self.botconfig["connection"]["port"] - self.irc.ipv6 = True if self.botconfig["connection"]["ipv6"] == "on" else False - - self.irc.addHook("_DISCONNECT", self.connection_closed) + """IRC protocol handler""" + self.irc = IRCCore(servers=self.botconfig["connection"]["servers"]) # legacy support self.act_PONG = self.irc.act_PONG @@ -64,20 +60,17 @@ class PyIRCBot(object): # Load modules self.initModules() - # Connect to IRC - self.connect() + def run(self): + self.loop = asyncio.get_event_loop() - def connect(self): + self.client = asyncio.ensure_future(self.irc.loop(self.loop), loop=self.loop) try: - self.irc._connect() - except: - self.log.error("Pyircbot attempted to connect and failed!") - self.log.error(traceback.format_exc()) + self.loop.set_debug(True) + self.loop.run_until_complete(self.client) + finally: + logging.debug("Escaped main loop") - def loop(self): - self.irc.loop() - - def disconnect(self, message, reconnect=True): + def disconnect(self, message): """Send quit message and disconnect from IRC. :param message: Quit message @@ -86,9 +79,9 @@ class PyIRCBot(object): :type reconnect: bool """ self.log.info("disconnect") - self.irc.kill(message=message, alive=reconnect) + self.kill(message=message) - def kill(self, sys_exit=True, message="Help! Another thread is killing me :("): + def kill(self, message="Help! Another thread is killing me :("): """Shut down the bot violently :param sys_exit: True causes sys.exit(0) to be called @@ -97,18 +90,7 @@ class PyIRCBot(object): :type message: str """ self.closeAllModules() - - self.irc.kill(message=message, alive=not sys_exit) - - if sys_exit: - sys.exit(0) - - def connection_closed(self, args, prefix, trailing): - """Called when the socket is disconnected. We will want to reconnect. """ - if self.irc.alive: - self.log.warning("Connection was lost. Reconnecting in 5 seconds.") - time.sleep(5) - self.connect() + asyncio.run_coroutine_threadsafe(self.irc.kill(message=message), self.loop) def initModules(self): """load modules specified in instance config"""