diff --git a/dr_botzo/dr_botzo/settings.py b/dr_botzo/dr_botzo/settings.py index b53007b..6702a77 100644 --- a/dr_botzo/dr_botzo/settings.py +++ b/dr_botzo/dr_botzo/settings.py @@ -36,6 +36,7 @@ INSTALLED_APPS = ( 'django.contrib.messages', 'django.contrib.staticfiles', 'django_extensions', + 'ircbot', 'markov', 'races', 'seen', @@ -113,6 +114,21 @@ STATICFILES_DIRS = ( os.path.join(BASE_DIR, 'static'), ) + +# IRC bot stuff + +# tuple of hostname, port number, and password (or None) +IRCBOT_SERVER_LIST = [ + ('localhost', 6667, None), +] +IRCBOT_NICKNAME = 'dr_botzo' +IRCBOT_REALNAME = 'Dr. Botzo' +IRCBOT_SSL = False +IRCBOT_IPV6 = False + + +# load local settings + try: from dr_botzo.localsettings import * except ImportError: diff --git a/dr_botzo/ircbot/__init__.py b/dr_botzo/ircbot/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dr_botzo/ircbot/ircbot.py b/dr_botzo/ircbot/ircbot.py new file mode 100644 index 0000000..3f4b21c --- /dev/null +++ b/dr_botzo/ircbot/ircbot.py @@ -0,0 +1,370 @@ +"""Provide the base IRC client bot which other code can latch onto.""" + +import ssl +import sys + +from django.conf import settings + +import irc.client +from irc.connection import Factory +from irc.dict import IRCDict +import irc.modes + + +class IRCBot(irc.client.SimpleIRCClient): + """A single-server IRC bot class.""" + + def __init__(self, reconnection_interval=60): + super(IRCBot, self).__init__() + + self.channels = IRCDict() + + # set up the server list + self.server_list = settings.IRCBOT_SERVER_LIST + + # set reconnection interval + if not reconnection_interval or reconnection_interval < 0: + reconnection_interval = 2 ** 31 + self.reconnection_interval = reconnection_interval + + # set basic stuff + self._nickname = settings.IRCBOT_NICKNAME + self._realname = settings.IRCBOT_REALNAME + + # handlers + for i in ['disconnect', 'join', 'kick', 'mode', 'namreply', 'nick', 'part', 'quit']: + self.connection.add_global_handler(i, getattr(self, '_on_' + i), -20) + + def _connected_checker(self): + if not self.connection.is_connected(): + self.connection.execute_delayed(self.reconnection_interval, + self._connected_checker) + self.jump_server() + + def _connect(self): + server = self.server_list[0] + try: + # build the connection factory as determined by IPV6/SSL settings + if settings.IRCBOT_SSL: + connect_factory = Factory(wrapper=ssl.wrap_socket, ipv6=settings.IRCBOT_IPV6) + else: + connect_factory = Factory(ipv6=settings.IRCBOT_IPV6) + + self.connect(server[0], server[1], self._nickname, server[2], ircname=self._realname, + connect_factory=connect_factory) + except irc.client.ServerConnectionError: + pass + + def _on_disconnect(self, c, e): + self.channels = IRCDict() + self.connection.execute_delayed(self.reconnection_interval, + self._connected_checker) + + def _on_join(self, c, e): + ch = e.target + nick = e.source.nick + if nick == c.get_nickname(): + self.channels[ch] = Channel() + self.channels[ch].add_user(nick) + + def _on_kick(self, c, e): + nick = e.arguments[0] + channel = e.target + + if nick == c.get_nickname(): + del self.channels[channel] + else: + self.channels[channel].remove_user(nick) + + def _on_mode(self, c, e): + modes = irc.modes.parse_channel_modes(" ".join(e.arguments)) + t = e.target + if irc.client.is_channel(t): + ch = self.channels[t] + for mode in modes: + if mode[0] == "+": + f = ch.set_mode + else: + f = ch.clear_mode + f(mode[1], mode[2]) + else: + # Mode on self... XXX + pass + + def _on_namreply(self, c, e): + """Get the list of names in a channel. + + e.arguments[0] == "@" for secret channels, + "*" for private channels, + "=" for others (public channels) + e.arguments[1] == channel + e.arguments[2] == nick list + """ + + ch_type, channel, nick_list = e.arguments + + if channel == '*': + # User is not in any visible channel + # http://tools.ietf.org/html/rfc2812#section-3.2.5 + return + + for nick in nick_list.split(): + nick_modes = [] + + if nick[0] in self.connection.features.prefix: + nick_modes.append(self.connection.features.prefix[nick[0]]) + nick = nick[1:] + + for mode in nick_modes: + self.channels[channel].set_mode(mode, nick) + + self.channels[channel].add_user(nick) + + def _on_nick(self, c, e): + before = e.source.nick + after = e.target + for ch in self.channels.values(): + if ch.has_user(before): + ch.change_nick(before, after) + + def _on_part(self, c, e): + nick = e.source.nick + channel = e.target + + if nick == c.get_nickname(): + del self.channels[channel] + else: + self.channels[channel].remove_user(nick) + + def _on_quit(self, c, e): + nick = e.source.nick + for ch in self.channels.values(): + if ch.has_user(nick): + ch.remove_user(nick) + + def die(self, msg="Bye, cruel world!"): + """Let the bot die. + + Arguments: + + msg -- Quit message. + """ + + self.connection.disconnect(msg) + sys.exit(0) + + def disconnect(self, msg="I'll be back!"): + """Disconnect the bot. + + The bot will try to reconnect after a while. + + Arguments: + + msg -- Quit message. + """ + + self.connection.disconnect(msg) + + def get_version(self): + """Returns the bot version. + + Used when answering a CTCP VERSION request. + """ + + return "Python irc.bot ({version})".format( + version=irc.client.VERSION_STRING) + + + def jump_server(self, msg="Changing servers"): + """Connect to a new server, potentially disconnecting from the current one.""" + + if self.connection.is_connected(): + self.connection.disconnect(msg) + + self.server_list.append(self.server_list.pop(0)) + self._connect() + + def on_ctcp(self, c, e): + """Default handler for ctcp events. + + Replies to VERSION and PING requests and relays DCC requests + to the on_dccchat method. + """ + + nick = e.source.nick + if e.arguments[0] == "VERSION": + c.ctcp_reply(nick, "VERSION " + self.get_version()) + elif e.arguments[0] == "PING": + if len(e.arguments) > 1: + c.ctcp_reply(nick, "PING " + e.arguments[1]) + elif e.arguments[0] == "DCC" and e.arguments[1].split(" ", 1)[0] == "CHAT": + self.on_dccchat(c, e) + + def on_dccchat(self, c, e): + pass + + def start(self): + """Start the bot.""" + + self._connect() + super(IRCBot, self).start() + + +class Channel(object): + """A class for keeping information about an IRC channel.""" + + def __init__(self): + self.userdict = IRCDict() + self.operdict = IRCDict() + self.voiceddict = IRCDict() + self.ownerdict = IRCDict() + self.halfopdict = IRCDict() + self.modes = {} + + def users(self): + """Returns an unsorted list of the channel's users.""" + + return self.userdict.keys() + + def opers(self): + """Returns an unsorted list of the channel's operators.""" + + return self.operdict.keys() + + def voiced(self): + """Returns an unsorted list of the persons that have voice + mode set in the channel.""" + + return self.voiceddict.keys() + + def owners(self): + """Returns an unsorted list of the channel's owners.""" + + return self.ownerdict.keys() + + def halfops(self): + """Returns an unsorted list of the channel's half-operators.""" + + return self.halfopdict.keys() + + def has_user(self, nick): + """Check whether the channel has a user.""" + + return nick in self.userdict + + def is_oper(self, nick): + """Check whether a user has operator status in the channel.""" + + return nick in self.operdict + + def is_voiced(self, nick): + """Check whether a user has voice mode set in the channel.""" + + return nick in self.voiceddict + + def is_owner(self, nick): + """Check whether a user has owner status in the channel.""" + + return nick in self.ownerdict + + def is_halfop(self, nick): + """Check whether a user has half-operator status in the channel.""" + + return nick in self.halfopdict + + def add_user(self, nick): + self.userdict[nick] = 1 + + def remove_user(self, nick): + for d in self.userdict, self.operdict, self.voiceddict: + if nick in d: + del d[nick] + + def change_nick(self, before, after): + self.userdict[after] = self.userdict.pop(before) + if before in self.operdict: + self.operdict[after] = self.operdict.pop(before) + if before in self.voiceddict: + self.voiceddict[after] = self.voiceddict.pop(before) + + def set_userdetails(self, nick, details): + if nick in self.userdict: + self.userdict[nick] = details + + def set_mode(self, mode, value=None): + """Set mode on the channel. + + Arguments: + + mode -- The mode (a single-character string). + + value -- Value + """ + + if mode == "o": + self.operdict[value] = 1 + elif mode == "v": + self.voiceddict[value] = 1 + elif mode == "q": + self.ownerdict[value] = 1 + elif mode == "h": + self.halfopdict[value] = 1 + else: + self.modes[mode] = value + + def clear_mode(self, mode, value=None): + """Clear mode on the channel. + + Arguments: + + mode -- The mode (a single-character string). + + value -- Value + """ + + try: + if mode == "o": + del self.operdict[value] + elif mode == "v": + del self.voiceddict[value] + elif mode == "q": + del self.ownerdict[value] + elif mode == "h": + del self.halfopdict[value] + else: + del self.modes[mode] + except KeyError: + pass + + def has_mode(self, mode): + return mode in self.modes + + def is_moderated(self): + return self.has_mode("m") + + def is_secret(self): + return self.has_mode("s") + + def is_protected(self): + return self.has_mode("p") + + def has_topic_lock(self): + return self.has_mode("t") + + def is_invite_only(self): + return self.has_mode("i") + + def has_allow_external_messages(self): + return self.has_mode("n") + + def has_limit(self): + return self.has_mode("l") + + def limit(self): + if self.has_limit(): + return self.modes["l"] + else: + return None + + def has_key(self): + return self.has_mode("k") diff --git a/dr_botzo/ircbot/management/__init__.py b/dr_botzo/ircbot/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dr_botzo/ircbot/management/commands/__init__.py b/dr_botzo/ircbot/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dr_botzo/ircbot/management/commands/runircbot.py b/dr_botzo/ircbot/management/commands/runircbot.py new file mode 100644 index 0000000..6e94f9f --- /dev/null +++ b/dr_botzo/ircbot/management/commands/runircbot.py @@ -0,0 +1,25 @@ +"""Start the IRC bot via Django management command.""" + +import logging + +from django.core.management import BaseCommand + +from ircbot.ircbot import IRCBot + + +log = logging.getLogger('ircbot') + + +class Command(BaseCommand): + """Provide the command to start the IRC bot. + + This will run until the bot disconnects and shuts down. + """ + + help = "Start the IRC bot" + + def handle(self, *args, **options): + """Start the IRC bot and spin forever.""" + + irc = IRCBot() + irc.start() diff --git a/dr_botzo/ircbot/migrations/__init__.py b/dr_botzo/ircbot/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/requirements.txt b/requirements.txt index 8a2ba17..f34aff4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,19 @@ Django==1.8.1 django-extensions==1.5.3 httplib2==0.7.4 +inflect==0.2.5 +irc==12.1.4 +jaraco.apt==1.0 +jaraco.classes==1.2 +jaraco.collections==1.1 +jaraco.context==1.3 +jaraco.functools==1.3 +jaraco.itertools==1.3 +jaraco.logging==1.2 +jaraco.text==1.3 logilab-astng==0.24.0 logilab-common==0.58.1 +more-itertools==2.2 MySQL-python==1.2.3 oauth2==1.5.211 oauthlib==0.5.1 @@ -13,4 +24,7 @@ python-dateutil==2.1 requests==1.2.3 requests-oauthlib==0.3.2 six==1.9.0 +tempora==1.3 twython==3.0.0 +yg.lockfile==2.0 +zc.lockfile==1.1.0