1 # synarere -- a highly modular and stable IRC bot.
2 # Copyright (C) 2010 Michael Rodriguez.
3 # Rights to this code are documented in docs/LICENSE.
5 '''This handles connections to IRC, sending/receiving data, dispatching commands and more.'''
7 # Import required Python modules.
8 import asyncore
, traceback
, os
, re
, socket
, time
9 from collections
import deque
10 from core
import shutdown
12 # Import required core modules.
13 import logger
, var
, timer
, command
, event
, misc
15 # A regular expression to match and dissect IRC protocol messages.
16 # This is actually around 60% faster than not using RE.
18 ^ # beginning of string
19 (?: # non-capturing group
20 \: # if we have a ':' then we have an origin
21 ([^\s]+) # get the origin without the ':'
22 \s # space after the origin
23 )? # close non-capturing group
24 (\w+) # must have a command
25 \s # and a space after it
26 (?: # non-capturing group
27 ([^\s\:]+) # a target for the command
28 \s # and a space after it
29 )? # close non-capturing group
30 (?: # non-capturing group
31 \:? # if we have a ':' then we have freeform text
32 (.*) # get the rest as one string without the ':'
33 )? # close non-capturing group
37 # Note that this doesn't match *every* IRC message,
38 # just the ones we care about. It also doesn't match
39 # every IRC message in the way we want. We get what
40 # we need. The rest is ignored.
42 # Here's a compact version if you need it:
43 # ^(?:\:([^\s]+)\s)?(\w+)\s(?:([^\s\:]+)\s)?(?:\:?(.*))?$
44 pattern
= re
.compile(pattern
, re
.VERBOSE
)
46 class Connection(asyncore
.dispatcher
):
47 '''Provide an event based IRC connection.'''
49 def __init__(self
, server
):
50 asyncore
.dispatcher
.__init
__(self
)
54 self
.last_recv
= time
.time()
60 # Add ourself to the connections list.
61 var
.conns
.append(self
)
64 '''See if we need to send data.'''
66 return len(self
.sendq
) > 0
68 def handle_read(self
):
69 '''Handle data read from the connection.'''
71 data
= self
.recv(8192)
72 self
.last_recv
= time
.time()
76 # This means the connection was closed.
77 # handle_close() takes care of all of this.
80 datalines
= data
.split('\r\n')
84 # Get rid of the empty element at the end.
88 # Check to see if we got part of a line previously.
89 # If we did, prepend it to the first line this time.
90 datalines
[0] = self
.holdline
+ datalines
[0]
93 if not data
.endswith('\r\n'):
94 # Check to make sure we got a full line at the end.
95 self
.holdline
= datalines
[-1]
98 # Add this jazz to the recvq.
99 self
.recvq
.extend([line
for line
in datalines
])
101 # Send it off to the parser.
104 def handle_write(self
):
105 '''Write the first line in the sendq to the socket.'''
107 # Grab the first line from the sendq.
108 line
= self
.sendq
[-1] + '\r\n'
109 stripped_line
= misc
.stripunicode(line
)
112 num_sent
= self
.send(stripped_line
)
114 # If it didn't all send we have to work this out.
115 if num_sent
== len(stripped_line
):
116 logger
.debug('%s: %s <- %s' % (self
.server
['id'], self
.server
['address'], self
.sendq
.pop()))
117 event
.dispatch('OnSocketWrite', self
.server
, line
)
119 logger
.warning('%s: Incomplete write (%d byte%s written instead of %d)' % (self
.server
['id'], num_sent
, 's' if num_sent
!= 1 else '', len(stripped_line
)))
120 event
.dispatch('OnIncompleteSocketWrite', self
.server
, num_sent
, stripped_line
)
121 self
.sendq
[-1] = self
.sendq
[-1][num_sent
:]
123 def handle_connect(self
):
124 '''Log into the IRC server.'''
126 logger
.info('%s: Connection established.' % (self
.server
['id']))
128 self
.server
['connected'] = True
129 event
.dispatch('OnConnect', self
.server
)
131 if self
.server
['pass']:
132 self
.sendq
.appendleft('PASS %s' % self
.server
['pass'])
134 self
.sendq
.appendleft('NICK %s' % self
.server
['nick'])
135 self
.sendq
.appendleft('USER %s 2 3 :%s' % (self
.server
['ident'], self
.server
['gecos']))
137 def handle_close(self
):
138 asyncore
.dispatcher
.close(self
)
140 logger
.info('%s: Connection lost.' % self
.server
['id'])
141 self
.server
['connected'] = False
143 event
.dispatch('OnConnectionClose', self
.server
)
145 if self
.server
['recontime']:
146 logger
.info('%s: Reconnecting in %d second%s.' % (self
.server
['id'], self
.server
['recontime'], 's' if self
.server
['recontime'] != 1 else ''))
147 timer
.add('io.reconnect', True, connect
, self
.server
['recontime'], self
.server
)
149 event
.dispatch('OnPostReconnect', self
.server
)
151 # Remove us from the connections list.
153 var
.conns
.remove(self
)
155 logger
.error('%s: Could not find myself in the connectons list (BUG)' % self
.server
['address'])
157 # I absolutely despise `compact_traceback()`.
158 def handle_error(self
):
159 '''Record the traceback and exit.'''
161 logger
.critical('Internal asyncore failure, writing traceback to %s' % var
.conf
.get('options', 'tbfile')[0])
164 tracefile
= open(var
.conf
.get('options', 'tbfile')[0], 'w')
165 traceback
.print_exc(file=tracefile
)
168 # Print one to the screen if we're not forked.
170 traceback
.print_exc()
174 shutdown(os
.EX_SOFTWARE
, 'asyncore failure')
177 '''Parse IRC protocol and call methods based on the results.'''
181 # Go through every line in the recvq.
182 while len(self
.recvq
):
183 line
= self
.recvq
.pop()
185 event
.dispatch('OnParse', self
.server
, line
)
187 logger
.debug('%s: %s -> %s' % (self
.server
['id'], self
.server
['address'], line
))
190 # Split this crap up with the help of RE.
192 origin
, cmd
, target
, message
= pattern
.match(line
).groups()
193 except AttributeError:
196 # Make an IRC parameter argument vector.
202 # Now see if the command is handled by the hash table.
208 if var
.conf
.get('options', 'irc_cmd_thread')[0]:
209 command
.dispatch(True, command
.irc
, cmd
, self
, origin
, parv
)
211 command
.dispatch(False, command
.irc
, cmd
, self
, origin
, parv
)
214 event
.dispatch('OnPING', self
.server
, parv
[0])
215 self
.sendq
.appendleft('PONG :%s' % parv
[0])
218 for i
in self
.server
['chans']:
219 self
.sendq
.appendleft('JOIN %s' % i
)
220 event
.dispatch('OnJoinChannel', self
.server
, i
)
224 n
, u
, h
= dissect_origin(origin
)
228 # Check to see if it's a channel.
229 if parv
[0].startswith('#') or parv
[0].startswith('&'):
230 # Do the `chan_cmd` related stuff.
231 cmd
= parv
[1].split()
236 # Chop the command off, as we don't want that.
238 message
= ' '.join(message
)
241 # Have we been addressed?
242 # If so, do the `chanme_cmd` related stuff.
243 if parv
[1].startswith(self
.server
['nick']):
244 message
= message
.split()
249 cmd
= message
[0].upper()
251 message
= ' '.join(message
)
259 if var
.conf
.get('options', 'chanme_cmd_thread')[0]:
260 command
.dispatch(True, command
.chanme
, cmd
, self
, (n
, u
, h
), parv
[0], message
)
262 command
.dispatch(False, command
.chanme
, cmd
, self
, (n
, u
, h
), parv
[0], message
)
270 if var
.conf
.get('options', 'chan_cmd_thread')[0]:
271 command
.dispatch(True, command
.chan
, self
.server
['trigger'] + cmd
, self
, (n
, u
, h
), parv
[0], message
)
273 command
.dispatch(False, command
.chan
, self
.server
['trigger'] + cmd
, self
, (n
, u
, h
), parv
[0], message
)
276 if parv
[1].startswith('\1'):
277 parv
[1] = parv
[1].strip('\1')
278 cmd
= parv
[1].split()
284 message
= ' '.join(message
)
293 if var
.conf
.get('options', 'ctcp_cmd_thread')[0]:
294 command
.dispatch(True, command
.ctcp
, cmd
, self
, (n
, u
, h
), message
)
296 command
.dispatch(False, command
.ctcp
, cmd
, self
, (n
, u
, h
), message
)
298 cmd
= parv
[1].split()
304 message
= ' '.join(message
)
317 if var
.conf
.get('options', 'priv_cmd_thread')[0]:
318 command
.dispatch(True, command
.priv
, cmd
, self
, (n
, u
, h
), message
)
320 command
.dispatch(False, command
.priv
, cmd
, self
, (n
, u
, h
), message
)
322 def privmsg(self
, where
, text
):
323 '''PRIVMSG 'where' with 'text'.'''
325 self
.sendq
.appendleft('PRIVMSG %s :%s' % (where
, text
))
326 event
.dispatch('OnPRIVMSG', self
.server
, where
, text
)
329 def notice(self
, where
, text
):
330 '''NOTICE 'where' with 'text'.'''
332 self
.sendq
.appendleft('NOTICE %s :%s' % (where
, text
))
333 event
.dispatch('OnNOTICE', self
.server
, where
, text
)
336 def join(self
, channel
, key
=None):
337 '''Join 'channel' with 'key' if present.'''
340 event
.dispatch('OnJoinChannel', self
.server
, channel
)
341 self
.sendq
.appendleft('JOIN %s' % channel
)
344 self
.sendq
.appendleft('JOIN %s :%s' % (channel
, key
))
345 event
.dispatch('OnJoinChannelWithKey', self
.server
, channel
, key
)
348 def part(self
, channel
, reason
=None):
349 '''Part 'channel' with 'reason' if present.'''
352 event
.dispatch('OnPartChannel', self
.server
, channel
)
353 self
.sendq
.appendleft('PART %s' % channel
)
356 event
.dispatch('OnPartChannelWithReason', self
.server
, channel
, reason
)
357 self
.sendq
.appendleft('PART %s :%s' % (channel
, reason
))
360 def quit(self
, reason
=None):
361 '''QUIT the server with 'reason'. This offers no reconnection.'''
364 self
.sendq
.appendleft('QUIT')
365 event
.dispatch('OnQuit', self
.server
)
368 self
.sendq
.appendleft('QUIT :%s' % reason
)
369 event
.dispatch('OnQuitWithReason', self
.server
, reason
)
372 def push(self
, data
):
373 '''Push raw data onto the server.'''
375 self
.sendq
.appendleft('%s' % data
)
379 '''Connect to an IRC server.'''
381 if server
['connected']:
384 logger
.info('%s: Connecting to %s:%d' % (server
['id'], server
['address'], server
['port']))
385 conn
= Connection(server
)
387 event
.dispatch('OnPreConnect', server
)
389 # This step is low-level to permit IPv6.
390 af
, type, proto
, canon
, sa
= socket
.getaddrinfo(server
['address'], server
['port'], 0, 1)[0]
391 conn
.create_socket(af
, type)
393 # If there's a vhost, bind to it.
395 conn
.bind((server
['vhost'], 0))
397 # Now connect to the IRC server.
400 def connect_to_all():
401 '''Connect to all servers in the configuration.'''
403 for i
in var
.conf
.get('network'):
404 serv
= { 'id' : i
.get('id'),
405 'address' : i
.get('address'),
406 'port' : int(i
.get('port')),
407 'nick' : i
.get('nick'),
408 'ident' : i
.get('ident'),
409 'gecos' : i
.get('gecos'),
410 'vhost' : i
.get('vhost'),
413 'pass' : i
.get('pass'),
415 'trigger' : i
.get('trigger') }
417 serv
['chans'].append(i
.get('chans'))
419 if i
.get('recontime'):
420 serv
['recontime'] = int(i
.get('recontime'))
422 var
.servers
.append(serv
)
424 event
.dispatch('OnNewServer', serv
)
428 except socket
.error
, e
:
429 logger
.error('%s: Unable to connect - (%s)' % (serv
['id'], serv
['address'], serv
['port'], os
.strerror(e
.args
[0])))
431 def dissect_origin(origin
):
432 '''Split nick!user@host into nick, user, host.'''
435 n
, uh
= origin
.split('!')
442 def quit_all(reason
):
443 '''Quit all IRC networks.'''
446 if isinstance(i
, Connection
):