add a newline because we're cool
[synarere.git] / core / irc.py
blobc6386b9918353cd3befb4d5c4fb67c7992dc04f7
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.
17 pattern = r'''
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
34 $ # end of string
35 '''
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)
52 self.server = server
53 self.holdline = None
54 self.last_recv = time.time()
55 self.pinged = False
57 self.sendq = deque()
58 self.recvq = deque()
60 # Add ourself to the connections list.
61 var.conns.append(self)
63 def writable(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()
75 if not data:
76 # This means the connection was closed.
77 # handle_close() takes care of all of this.
78 return
80 datalines = data.split('\r\n')
83 if not datalines[-1]:
84 # Get rid of the empty element at the end.
85 datalines.pop()
87 if self.holdline:
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]
91 self.holdline = None
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]
96 datalines.pop()
98 # Add this jazz to the recvq.
99 self.recvq.extend([line for line in datalines])
101 # Send it off to the parser.
102 self.parse()
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)
111 # Try to send it.
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)
118 else:
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)
150 else:
151 # Remove us from the connections list.
152 try:
153 var.conns.remove(self)
154 except ValueError:
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])
163 try:
164 tracefile = open(var.conf.get('options', 'tbfile')[0], 'w')
165 traceback.print_exc(file=tracefile)
166 tracefile.close()
168 # Print one to the screen if we're not forked.
169 if not var.fork:
170 traceback.print_exc()
171 except:
172 raise
174 shutdown(os.EX_SOFTWARE, 'asyncore failure')
176 def parse(self):
177 '''Parse IRC protocol and call methods based on the results.'''
179 global pattern
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))
188 parv = []
190 # Split this crap up with the help of RE.
191 try:
192 origin, cmd, target, message = pattern.match(line).groups()
193 except AttributeError:
194 continue
196 # Make an IRC parameter argument vector.
197 if target:
198 parv.append(target)
200 parv.append(message)
202 # Now see if the command is handled by the hash table.
203 try:
204 command.irc[cmd]
205 except KeyError:
206 pass
208 if var.conf.get('options', 'irc_cmd_thread')[0]:
209 command.dispatch(True, command.irc, cmd, self, origin, parv)
210 else:
211 command.dispatch(False, command.irc, cmd, self, origin, parv)
213 if cmd == 'PING':
214 event.dispatch('OnPING', self.server, parv[0])
215 self.sendq.appendleft('PONG :%s' % parv[0])
217 if cmd == '001':
218 for i in self.server['chans']:
219 self.sendq.appendleft('JOIN %s' % i)
220 event.dispatch('OnJoinChannel', self.server, i)
222 if cmd == 'PRIVMSG':
223 try:
224 n, u, h = dissect_origin(origin)
225 except:
226 return
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()
233 if not cmd:
234 return
236 # Chop the command off, as we don't want that.
237 message = cmd[1:]
238 message = ' '.join(message)
239 cmd = cmd[0].upper()
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()
246 if not message:
247 return
249 cmd = message[0].upper()
250 del message[0]
251 message = ' '.join(message)
253 # Call the handlers.
254 try:
255 command.chanme[cmd]
256 except KeyError:
257 return
259 if var.conf.get('options', 'chanme_cmd_thread')[0]:
260 command.dispatch(True, command.chanme, cmd, self, (n, u, h), parv[0], message)
261 else:
262 command.dispatch(False, command.chanme, cmd, self, (n, u, h), parv[0], message)
263 else:
264 # Call the handlers.
265 try:
266 command.chan[cmd]
267 except KeyError:
268 return
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)
272 else:
273 command.dispatch(False, command.chan, self.server['trigger'] + cmd, self, (n, u, h), parv[0], message)
274 else:
275 # CTCP?
276 if parv[1].startswith('\1'):
277 parv[1] = parv[1].strip('\1')
278 cmd = parv[1].split()
280 if not cmd:
281 return
283 message = cmd[1:]
284 message = ' '.join(message)
285 cmd = cmd[0].upper()
287 # Call the handlers.
288 try:
289 command.ctcp[cmd]
290 except KeyError:
291 return
293 if var.conf.get('options', 'ctcp_cmd_thread')[0]:
294 command.dispatch(True, command.ctcp, cmd, self, (n, u, h), message)
295 else:
296 command.dispatch(False, command.ctcp, cmd, self, (n, u, h), message)
297 else:
298 cmd = parv[1].split()
300 if not cmd:
301 return
303 message = cmd[1:]
304 message = ' '.join(message)
305 cmd = cmd[0].upper()
307 # Call the handlers.
308 try:
309 command.priv [cmd]
310 except KeyError:
311 try:
312 cmd = cmd[1:]
313 command.priv[cmd]
314 except KeyError:
315 return
317 if var.conf.get('options', 'priv_cmd_thread')[0]:
318 command.dispatch(True, command.priv, cmd, self, (n, u, h), message)
319 else:
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)
327 return
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)
334 return
336 def join(self, channel, key=None):
337 '''Join 'channel' with 'key' if present.'''
339 if not key:
340 event.dispatch('OnJoinChannel', self.server, channel)
341 self.sendq.appendleft('JOIN %s' % channel)
342 return
344 self.sendq.appendleft('JOIN %s :%s' % (channel, key))
345 event.dispatch('OnJoinChannelWithKey', self.server, channel, key)
346 return
348 def part(self, channel, reason=None):
349 '''Part 'channel' with 'reason' if present.'''
351 if not reason:
352 event.dispatch('OnPartChannel', self.server, channel)
353 self.sendq.appendleft('PART %s' % channel)
354 return
356 event.dispatch('OnPartChannelWithReason', self.server, channel, reason)
357 self.sendq.appendleft('PART %s :%s' % (channel, reason))
358 return
360 def quit(self, reason=None):
361 '''QUIT the server with 'reason'. This offers no reconnection.'''
363 if not reason:
364 self.sendq.appendleft('QUIT')
365 event.dispatch('OnQuit', self.server)
366 return
368 self.sendq.appendleft('QUIT :%s' % reason)
369 event.dispatch('OnQuitWithReason', self.server, reason)
370 return
372 def push(self, data):
373 '''Push raw data onto the server.'''
375 self.sendq.appendleft('%s' % data)
376 return
378 def connect(server):
379 '''Connect to an IRC server.'''
381 if server['connected']:
382 return
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.
394 if server['vhost']:
395 conn.bind((server['vhost'], 0))
397 # Now connect to the IRC server.
398 conn.connect(sa)
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'),
411 'chans' : [],
412 'connected' : False,
413 'pass' : i.get('pass'),
414 'recontime' : 0,
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)
426 try:
427 connect(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.'''
434 try:
435 n, uh = origin.split('!')
436 u, h = uh.split('@')
438 return n, u, h
439 except ValueError:
440 pass
442 def quit_all(reason):
443 '''Quit all IRC networks.'''
445 for i in var.conns:
446 if isinstance(i, Connection):
447 i.quit(reason)