|
| 1 | +# -*- coding: utf-8 -*- |
| 2 | +# |
| 3 | +# This file is part of IRC Bot Behavior Bundle (IB3) |
| 4 | +# Copyright (C) 2017 Bryan Davis and contributors |
| 5 | +# |
| 6 | +# This program is free software: you can redistribute it and/or modify it |
| 7 | +# under the terms of the GNU General Public License as published by the Free |
| 8 | +# Software Foundation, either version 3 of the License, or (at your option) |
| 9 | +# any later version. |
| 10 | +# |
| 11 | +# This program is distributed in the hope that it will be useful, but WITHOUT |
| 12 | +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or |
| 13 | +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for |
| 14 | +# more details. |
| 15 | +# |
| 16 | +# You should have received a copy of the GNU General Public License along with |
| 17 | +# this program. If not, see <http://www.gnu.org/licenses/>. |
| 18 | +"""Base IRC bot and mixins for commonly desired functionality.""" |
| 19 | + |
| 20 | +import functools |
| 21 | +import logging |
| 22 | + |
| 23 | +import irc.bot |
| 24 | +import irc.buffer |
| 25 | +import irc.client |
| 26 | + |
| 27 | +logger = logging.getLogger(__name__) |
| 28 | + |
| 29 | + |
| 30 | +class Bot(irc.bot.SingleServerIRCBot): |
| 31 | + """Basic IRC bot""" |
| 32 | + def __init__(self, *args, **kwargs): |
| 33 | + # A UTF-8 only world is a nice dream but the real world is all yucky |
| 34 | + # and full of legacy encoding issues that should not crash our bot. |
| 35 | + irc.buffer.LenientDecodingLineBuffer.errors = 'replace' |
| 36 | + irc.client.ServerConnection.buffer_class = \ |
| 37 | + irc.buffer.LenientDecodingLineBuffer |
| 38 | + |
| 39 | + super(Bot, self).__init__(*args, **kwargs) |
| 40 | + |
| 41 | + |
| 42 | +class Ping(object): |
| 43 | + """Add checks for connection liveness using PING commands.""" |
| 44 | + def __init__(self, max_pings=2, ping_interval=300, *args, **kwargs): |
| 45 | + """ |
| 46 | + :param max_pings: Maximum numer of missed pings to tolerate |
| 47 | + :param ping_interval: Seconds between ping attempts |
| 48 | + """ |
| 49 | + super(Ping, self).__init__(*args, **kwargs) |
| 50 | + self._unanswered_pings = 0 |
| 51 | + self._unanswered_pings_limit = max_pings |
| 52 | + self.reactor.scheduler.execute_every( |
| 53 | + period=ping_interval, |
| 54 | + func=self._ping_server) |
| 55 | + self.connection.add_global_handler('pong', self._handle_pong) |
| 56 | + |
| 57 | + def _ping_server(self): |
| 58 | + """Send a ping or disconnect if too many pings are outstanding.""" |
| 59 | + if self._unanswered_pings >= self._unanswered_pings_limit: |
| 60 | + logger.warning('Connection timed out. Disconnecting.') |
| 61 | + self.disconnect() |
| 62 | + self._unanswered_pings = 0 |
| 63 | + else: |
| 64 | + try: |
| 65 | + self.connection.ping('keep-alive') |
| 66 | + self._unanswered_pings += 1 |
| 67 | + except irc.client.ServerNotConnectedError: |
| 68 | + pass |
| 69 | + |
| 70 | + def _handle_pong(self, conn, event): |
| 71 | + """Clear ping count when a pong is received.""" |
| 72 | + self._unanswered_pings = 0 |
| 73 | + |
| 74 | + |
| 75 | +class DisconnectOnError(object): |
| 76 | + """Handle ERROR message by logging and disconnecting.""" |
| 77 | + def __init__(self, *args, **kwargs): |
| 78 | + super(DisconnectOnError, self).__init__(*args, **kwargs) |
| 79 | + self.connection.add_global_handler('error', self._handle_error) |
| 80 | + |
| 81 | + def _handle_error(self, conn, event): |
| 82 | + logger.warning(str(event)) |
| 83 | + conn.disconnect() |
| 84 | + |
| 85 | + |
| 86 | +class RejoinOnKick(object): |
| 87 | + """Handle KICK by attempting to rejoin channel.""" |
| 88 | + def __init__(self, *args, **kwargs): |
| 89 | + super(RejoinOnKick, self).__init__(*args, **kwargs) |
| 90 | + self.connection.add_global_handler('kick', self._handle_kick) |
| 91 | + |
| 92 | + def _handle_kick(self, conn, event): |
| 93 | + nick = event.arguments[0] |
| 94 | + channel = event.target |
| 95 | + if nick == conn.get_nickname(): |
| 96 | + logger.warn( |
| 97 | + 'Kicked from %s by %s', channel, event.source.nick) |
| 98 | + self.reactor.scheduler.execute_after( |
| 99 | + 30, functools.partial(conn.join, channel)) |
| 100 | + |
| 101 | + |
| 102 | +class RejoinOnBan(object): |
| 103 | + """Handle ERR_BANNEDFROMCHAN by attempting to rejoin channel.""" |
| 104 | + def __init__(self, *args, **kwargs): |
| 105 | + super(RejoinOnKick, self).__init__(*args, **kwargs) |
| 106 | + self.connection.add_global_handler( |
| 107 | + 'bannedfromchan', self._handle_bannedfromchan) |
| 108 | + |
| 109 | + def _handle_bannedfromchan(self, conn, event): |
| 110 | + logger.warning(str(event)) |
| 111 | + self.reactor.scheduler.execute_after( |
| 112 | + 60, functools.partial(conn.join, event.arguments[0])) |
| 113 | + |
| 114 | + |
| 115 | +class FreenodePasswdAuth(object): |
| 116 | + """Authenticate with NickServ before joining channels.""" |
| 117 | + def __init__( |
| 118 | + self, server_list, nickname, realname, |
| 119 | + ident_password, channels, **kwargs): |
| 120 | + """ |
| 121 | + :param server_list: List of servers the bot will use. |
| 122 | + :param nickname: The bot's nickname |
| 123 | + :param realname: The bot's realname |
| 124 | + :param ident_password: The bot's password |
| 125 | + :param channels: List of channels to join after authenticating |
| 126 | + """ |
| 127 | + self._primary_nick = nickname |
| 128 | + self._ident_password = ident_password |
| 129 | + self._channels = channels |
| 130 | + |
| 131 | + super(FreenodePasswdAuth, self).__init__( |
| 132 | + server_list=server_list, |
| 133 | + nickname=nickname, |
| 134 | + realname=realname, |
| 135 | + **kwargs) |
| 136 | + for event in ['welcome', 'nicknameinuse', 'privnotice']: |
| 137 | + self.connection.add_global_handler( |
| 138 | + event, getattr(self, "_handle_%s" % event)) |
| 139 | + |
| 140 | + def _handle_welcome(self, conn, event): |
| 141 | + """Handle WELCOME message. |
| 142 | +
|
| 143 | + Starts authentication handshake by sending NickServ an `identify` |
| 144 | + message. |
| 145 | + """ |
| 146 | + logger.info('Connected to server %s', conn.get_server_name()) |
| 147 | + self._identify_to_nickserv() |
| 148 | + |
| 149 | + def _handle_nicknameinuse(self, conn, event): |
| 150 | + """Handle ERR_NICKNAMEINUSE message. |
| 151 | +
|
| 152 | + If failed nick matches our desired nick, switch to secondary nick by |
| 153 | + appending `_` to the desired nick and schedule an attempt to reclaim |
| 154 | + our primary nick. |
| 155 | +
|
| 156 | + If failed nick matches our secondary nick, die. |
| 157 | + """ |
| 158 | + nick = conn.get_nickname() |
| 159 | + logger.warning('Requested nick "%s" in use', nick) |
| 160 | + alt_nick = self._primary_nick + '_' |
| 161 | + if nick == alt_nick: |
| 162 | + # Primary and secondary nicks taken, abort connection |
| 163 | + self.die( |
| 164 | + 'Cowardly refusing to fill the channel with copies of myself') |
| 165 | + conn.nick(alt_nick) |
| 166 | + self.reactor.scheduler.execute_after(30, self._nickserv_regain) |
| 167 | + |
| 168 | + def _handle_privnotice(self, conn, event): |
| 169 | + """Handle NOTICE sent directly to user. |
| 170 | +
|
| 171 | + Check for messages from NickServ requesting auth, warning of password |
| 172 | + failures, and acknowledging successful auth. |
| 173 | + """ |
| 174 | + msg = event.arguments[0] |
| 175 | + if event.source.nick == 'NickServ': |
| 176 | + if 'NickServ identify' in msg: |
| 177 | + logger.info('Authentication requested by Nickserv: %s', msg) |
| 178 | + self._identify_to_nickserv() |
| 179 | + elif 'You are now identified' in msg: |
| 180 | + logger.debug('Authentication succeeded') |
| 181 | + self.reactor.scheduler.execute_after( |
| 182 | + 1, self._join_next_channel) |
| 183 | + elif 'Invalid password' in msg: |
| 184 | + logger.error('Password invalid. Check your config!') |
| 185 | + self.die() |
| 186 | + |
| 187 | + def _identify_to_nickserv(self): |
| 188 | + """Send NickServ our username and password.""" |
| 189 | + logger.info('Authenticating to NickServ') |
| 190 | + self.connection.privmsg('NickServ', 'identify %s %s' % ( |
| 191 | + self._primary_nick, self._ident_password)) |
| 192 | + |
| 193 | + def _join_next_channel(self, channels=None): |
| 194 | + """Join the next channel in our join list.""" |
| 195 | + if channels is None: |
| 196 | + channels = self._channels |
| 197 | + try: |
| 198 | + car, cdr = channels[0], channels[1:] |
| 199 | + except (IndexError, TypeError): |
| 200 | + logger.exception('Failed to find channel to join.') |
| 201 | + else: |
| 202 | + logger.info('Joining %s', car) |
| 203 | + self.connection.join(car) |
| 204 | + if cdr: |
| 205 | + self.reactor.scheduler.execute_after( |
| 206 | + 1, functools.partial(self._join_next_channel, cdr)) |
| 207 | + |
| 208 | + def _nickserv_regain(self): |
| 209 | + if not self.has_primary_nick(): |
| 210 | + # REGAIN disconnects an old user session, or somebody |
| 211 | + # attempting to use your nickname without authorization, |
| 212 | + # then changes your nickname to the given nickname. |
| 213 | + # This may not work, disconnecting you, if the target |
| 214 | + # client reconnects automatically. |
| 215 | + self.connection.privmsg('NickServ', 'regain %s %s' % ( |
| 216 | + self._primary_nick, self._ident_password)) |
| 217 | + |
| 218 | + def has_primary_nick(self): |
| 219 | + """Do we currently have our primary nick?""" |
| 220 | + return self.connection.get_nickname() == self._primary_nick |
0 commit comments