Skip to content

Commit 2cb3028

Browse files
committed
Initial commit
0 parents  commit 2cb3028

13 files changed

Lines changed: 1120 additions & 0 deletions

File tree

.gitignore

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
*.egg-info
2+
*.py[co]
3+
/.cache/
4+
/.coverage
5+
/.eggs/
6+
/.tox/
7+
/.venv/
8+
/build/
9+
/build/
10+
/dist/
11+
/dist/
12+
__pycache__/

.travis.yml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
language: python
2+
python:
3+
- "2.7"
4+
- "3.5"
5+
sudo: false
6+
matrix:
7+
fast_finish: true
8+
9+
install:
10+
- pip install wheel tox-travis
11+
- python setup.py install bdist_wheel
12+
- pip install ./dist/ib3-*.whl
13+
script:
14+
- tox
15+
- tox --installpkg ./dist/ib3-*.whl

COPYING

Lines changed: 674 additions & 0 deletions
Large diffs are not rendered by default.

MANIFEST.in

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
include *.rst LICENSE

README.rst

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
=============================
2+
IRC Bot Behavior Bundle (IB3)
3+
=============================
4+
5+
IRC bot framework using mixins to provide commonly desired functionality.
6+
7+
Mixins
8+
======
9+
* DisconnectOnError: Handle ERROR message by logging and disconnecting
10+
* FreenodePasswdAuth: Authenticate with NickServ before joining channels
11+
* Ping: Add checks for connection liveness using PING commands
12+
* RejoinOnBan: Handle ERR_BANNEDFROMCHAN by attempting to rejoin channel
13+
* RejoinOnKick: Handle KICK by attempting to rejoin channel
14+
15+
License
16+
=======
17+
`GNU GPLv3+`_
18+
19+
Some code and much inspiration taken from:
20+
21+
* `Adminbot`_
22+
* `Jouncebot`_
23+
* `Stashbot`_
24+
25+
.. _GNU GPLv3+: https://www.gnu.org/copyleft/gpl.html
26+
.. _Adminbot: https://phabricator.wikimedia.org/diffusion/ODAC/
27+
.. _Jouncebot: https://phabricator.wikimedia.org/diffusion/GJOU/
28+
.. _Stashbot: https://phabricator.wikimedia.org/diffusion/LTST/

doc/_static/.gitkeep

Whitespace-only changes.

doc/conf.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
#!/usr/bin/env python3
2+
# -*- coding: utf-8 -*-
3+
4+
import datetime
5+
import os
6+
import subprocess
7+
import sys
8+
9+
import sphinx_rtd_theme
10+
11+
if 'check_output' not in dir(subprocess):
12+
import subprocess32 as subprocess
13+
14+
sys.path.insert(0, os.path.abspath('../'))
15+
16+
extensions = [
17+
'sphinx.ext.autodoc',
18+
'sphinx.ext.viewcode',
19+
]
20+
21+
# General information about the project.
22+
23+
root = os.path.join(os.path.dirname(__file__), '..')
24+
setup_script = os.path.join(root, 'setup.py')
25+
fields = ['--name', '--version', '--author']
26+
dist_info_cmd = [sys.executable, setup_script] + fields
27+
output_bytes = subprocess.check_output(dist_info_cmd, cwd=root)
28+
project, version, author = output_bytes.decode('utf-8').strip().split('\n')
29+
30+
_origin_date = datetime.date(2017, 2, 19)
31+
_today = datetime.date.today()
32+
33+
copyright = '{_origin_date.year}-{_today.year} {author}'.format(**locals())
34+
35+
release = version
36+
37+
master_doc = 'index'
38+
pygments_style = 'sphinx'
39+
html_theme = 'sphinx_rtd_theme'
40+
html_theme_path = [sphinx_rtd_theme.get_html_theme_path()]
41+
html_static_path = ['_static']

doc/index.rst

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
.. toctree::
2+
:maxdepth: 2
3+
4+
.. include:: ../README.rst
5+
6+
ib3 package
7+
===========
8+
9+
.. automodule:: ib3
10+
:members:
11+
:undoc-members:
12+
:show-inheritance:
13+
:noindex:

ib3/__init__.py

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
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

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
irc>=15.0.3

0 commit comments

Comments
 (0)