PageRenderTime 54ms CodeModel.GetById 17ms RepoModel.GetById 0ms app.codeStats 0ms

/irc.py

http://github.com/coleifer/irc
Python | 359 lines | 252 code | 47 blank | 60 comment | 45 complexity | bb3386de1e80e655839f1444574eb86d MD5 | raw file
  1. import logging
  2. import os
  3. import random
  4. import re
  5. import sys
  6. import time
  7. import ssl
  8. try:
  9. from gevent import socket
  10. except ImportError:
  11. import socket
  12. from logging.handlers import RotatingFileHandler
  13. from optparse import OptionParser
  14. class IRCConnection(object):
  15. """\
  16. Connection class for connecting to IRC servers
  17. """
  18. # a couple handy regexes for reading text
  19. nick_re = re.compile('.*?Nickname is already in use')
  20. nick_change_re = re.compile(':(?P<old_nick>.*?)!\S+\s+?NICK\s+:\s*(?P<new_nick>[-\w]+)')
  21. ping_re = re.compile('^PING (?P<payload>.*)')
  22. chanmsg_re = re.compile(':(?P<nick>.*?)!\S+\s+?PRIVMSG\s+(?P<channel>#+[-\w]+)\s+:(?P<message>[^\n\r]+)')
  23. privmsg_re = re.compile(':(?P<nick>.*?)!~\S+\s+?PRIVMSG\s+[^#][^:]+:(?P<message>[^\n\r]+)')
  24. part_re = re.compile(':(?P<nick>.*?)!\S+\s+?PART\s+(?P<channel>#+[-\w]+)')
  25. join_re = re.compile(':(?P<nick>.*?)!\S+\s+?JOIN\s+.*?(?P<channel>#+[-\w]+)')
  26. quit_re = re.compile(':(?P<nick>.*?)!\S+\s+?QUIT\s+.*')
  27. registered_re = re.compile(':(?P<server>.*?)\s+(?:376|422)')
  28. # mapping for logging verbosity
  29. verbosity_map = {
  30. 0: logging.ERROR,
  31. 1: logging.INFO,
  32. 2: logging.DEBUG,
  33. }
  34. def __init__(self, server, port, nick, password=None, logfile=None, verbosity=1, needs_registration=True, ssl=None):
  35. self.server = server
  36. self.port = port
  37. self.nick = self.base_nick = nick
  38. self.password = password
  39. self.logfile = logfile
  40. self.verbosity = verbosity
  41. self._registered = not needs_registration
  42. self._out_buffer = []
  43. self._callbacks = []
  44. self.logger = self.get_logger('ircconnection.logger', self.logfile)
  45. self.use_ssl = False
  46. if ssl == True or port == 6697: # 6697 is the de facto standard SSL port for IRC
  47. self.use_ssl = True
  48. def get_logger(self, logger_name, filename):
  49. log = logging.getLogger(logger_name)
  50. log.setLevel(self.verbosity_map.get(self.verbosity, logging.INFO))
  51. if self.logfile:
  52. handler = RotatingFileHandler(filename, maxBytes=1024*1024, backupCount=2)
  53. handler.setFormatter(logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s'))
  54. log.addHandler(handler)
  55. if self.verbosity == 2 or not self.logfile:
  56. stream_handler = logging.StreamHandler()
  57. stream_handler.setFormatter(logging.Formatter('%(asctime)s - %(levelname)s - %(message)s'))
  58. log.addHandler(stream_handler)
  59. return log
  60. def send(self, data, force=False):
  61. """\
  62. Send raw data over the wire if connection is registered. Otherewise,
  63. save the data to an output buffer for transmission later on.
  64. If the force flag is true, always send data, regardless of
  65. registration status.
  66. """
  67. if self._registered or force:
  68. self._sock_file.write('%s\r\n' % data)
  69. self._sock_file.flush()
  70. else:
  71. self._out_buffer.append(data)
  72. def connect(self):
  73. """\
  74. Connect to the IRC server using the nickname
  75. """
  76. self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  77. if self.use_ssl:
  78. self._sock = ssl.wrap_socket(self._sock)
  79. try:
  80. self._sock.connect((self.server, self.port))
  81. except socket.error:
  82. self.logger.error('Unable to connect to %s on port %d' % (self.server, self.port), exc_info=1)
  83. return False
  84. self._sock_file = self._sock.makefile()
  85. if self.password:
  86. self.set_password()
  87. self.register_nick()
  88. self.register()
  89. return True
  90. def close(self):
  91. self._sock.close()
  92. def set_password(self):
  93. self.logger.info('Setting password')
  94. self.send('PASS %s' % self.password, True)
  95. def register_nick(self):
  96. self.logger.info('Registering nick %s' % self.nick)
  97. self.send('NICK %s' % self.nick, True)
  98. def register(self):
  99. self.logger.info('Authing as %s' % self.nick)
  100. self.send('USER %s %s bla :%s' % (self.nick, self.server, self.nick), True)
  101. def join(self, channel):
  102. if not channel.startswith('#'):
  103. channel = '#%s' % channel
  104. self.send('JOIN %s' % channel)
  105. self.logger.debug('joining %s' % channel)
  106. def part(self, channel):
  107. if not channel.startswith('#'):
  108. channel = '#%s' % channel
  109. self.send('PART %s' % channel)
  110. self.logger.debug('leaving %s' % channel)
  111. def respond(self, message, channel=None, nick=None):
  112. """\
  113. Multipurpose method for sending responses to channel or via message to
  114. a single user
  115. """
  116. if channel:
  117. if not channel.startswith('#'):
  118. channel = '#%s' % channel
  119. self.send('PRIVMSG %s :%s' % (channel, message))
  120. elif nick:
  121. self.send('PRIVMSG %s :%s' % (nick, message))
  122. def dispatch_patterns(self):
  123. """\
  124. Low-level dispatching of socket data based on regex matching, in general
  125. handles
  126. * In event a nickname is taken, registers under a different one
  127. * Responds to periodic PING messages from server
  128. * Dispatches to registered callbacks when
  129. - any user leaves or enters a room currently connected to
  130. - a channel message is observed
  131. - a private message is received
  132. """
  133. return (
  134. (self.nick_re, self.new_nick),
  135. (self.nick_change_re, self.handle_nick_change),
  136. (self.ping_re, self.handle_ping),
  137. (self.part_re, self.handle_part),
  138. (self.join_re, self.handle_join),
  139. (self.quit_re, self.handle_quit),
  140. (self.chanmsg_re, self.handle_channel_message),
  141. (self.privmsg_re, self.handle_private_message),
  142. (self.registered_re, self.handle_registered),
  143. )
  144. def register_callbacks(self, callbacks):
  145. """\
  146. Hook for registering custom callbacks for dispatch patterns
  147. """
  148. self._callbacks.extend(callbacks)
  149. def new_nick(self):
  150. """\
  151. Generates a new nickname based on original nickname followed by a
  152. random number
  153. """
  154. old = self.nick
  155. self.nick = '%s_%s' % (self.base_nick, random.randint(1, 1000))
  156. self.logger.warn('Nick %s already taken, trying %s' % (old, self.nick))
  157. self.register_nick()
  158. self.handle_nick_change(old, self.nick)
  159. def handle_nick_change(self, old_nick, new_nick):
  160. for pattern, callback in self._callbacks:
  161. if pattern.match('/nick'):
  162. callback(old_nick, '/nick', new_nick)
  163. def handle_ping(self, payload):
  164. """\
  165. Respond to periodic PING messages from server
  166. """
  167. self.logger.info('server ping: %s' % payload)
  168. self.send('PONG %s' % payload, True)
  169. def handle_registered(self, server):
  170. """\
  171. When the connection to the server is registered, send all pending
  172. data.
  173. """
  174. if not self._registered:
  175. self.logger.info('Registered')
  176. self._registered = True
  177. for data in self._out_buffer:
  178. self.send(data)
  179. self._out_buffer = []
  180. def handle_part(self, nick, channel):
  181. for pattern, callback in self._callbacks:
  182. if pattern.match('/part'):
  183. callback(nick, '/part', channel)
  184. def handle_join(self, nick, channel):
  185. for pattern, callback in self._callbacks:
  186. if pattern.match('/join'):
  187. callback(nick, '/join', channel)
  188. def handle_quit(self, nick):
  189. for pattern, callback in self._callbacks:
  190. if pattern.match('/quit'):
  191. callback(nick, '/quit', None)
  192. def _process_command(self, nick, message, channel):
  193. results = []
  194. for pattern, callback in self._callbacks:
  195. match = pattern.match(message) or pattern.match('/privmsg')
  196. if match:
  197. results.append(callback(nick, message, channel, **match.groupdict()))
  198. return results
  199. def handle_channel_message(self, nick, channel, message):
  200. for result in self._process_command(nick, message, channel):
  201. if result:
  202. self.respond(result, channel=channel)
  203. def handle_private_message(self, nick, message):
  204. for result in self._process_command(nick, message, None):
  205. if result:
  206. self.respond(result, nick=nick)
  207. def enter_event_loop(self):
  208. """\
  209. Main loop of the IRCConnection - reads from the socket and dispatches
  210. based on regex matching
  211. """
  212. patterns = self.dispatch_patterns()
  213. self.logger.debug('entering receive loop')
  214. while 1:
  215. try:
  216. data = self._sock_file.readline()
  217. except socket.error:
  218. data = None
  219. if not data:
  220. self.logger.info('server closed connection')
  221. self.close()
  222. return True
  223. data = data.rstrip()
  224. for pattern, callback in patterns:
  225. match = pattern.match(data)
  226. if match:
  227. callback(**match.groupdict())
  228. class IRCBot(object):
  229. """\
  230. A class that interacts with the IRCConnection class to provide a simple way
  231. of registering callbacks and scripting IRC interactions
  232. """
  233. def __init__(self, conn):
  234. self.conn = conn
  235. # register callbacks with the connection
  236. self.register_callbacks()
  237. def register_callbacks(self):
  238. """\
  239. Hook for registering callbacks with connection -- handled by __init__()
  240. """
  241. self.conn.register_callbacks((
  242. (re.compile(pattern), callback) \
  243. for pattern, callback in self.command_patterns()
  244. ))
  245. def _ping_decorator(self, func):
  246. def inner(nick, message, channel, **kwargs):
  247. message = re.sub('^%s[:,\s]\s*' % self.conn.nick, '', message)
  248. return func(nick, message, channel, **kwargs)
  249. return inner
  250. def is_ping(self, message):
  251. return re.match('^%s[:,\s]' % self.conn.nick, message) is not None
  252. def fix_ping(self, message):
  253. return re.sub('^%s[:,\s]\s*' % self.conn.nick, '', message)
  254. def ping(self, pattern, callback):
  255. return (
  256. '^%s[:,\s]\s*%s' % (self.conn.nick, pattern.lstrip('^')),
  257. self._ping_decorator(callback),
  258. )
  259. def command_patterns(self):
  260. """\
  261. Hook for defining callbacks, stored as a tuple of 2-tuples:
  262. return (
  263. ('/join', self.room_greeter),
  264. ('!find (^\s+)', self.handle_find),
  265. )
  266. """
  267. raise NotImplementedError
  268. def respond(self, message, channel=None, nick=None):
  269. """\
  270. Wraps the connection object's respond() method
  271. """
  272. self.conn.respond(message, channel, nick)
  273. def run_bot(bot_class, host, port, nick, channels=None, ssl=None):
  274. """\
  275. Convenience function to start a bot on the given network, optionally joining
  276. some channels
  277. """
  278. conn = IRCConnection(host, port, nick, ssl)
  279. bot_instance = bot_class(conn)
  280. while 1:
  281. if not conn.connect():
  282. break
  283. channels = channels or []
  284. for channel in channels:
  285. conn.join(channel)
  286. conn.enter_event_loop()
  287. class SimpleSerialize(object):
  288. """\
  289. Allow simple serialization of data in IRC messages with minimum of space.
  290. * Only supports dictionaries *
  291. """
  292. def serialize(self, dictionary):
  293. return '|'.join(('%s:%s' % (k, v) for k, v in dictionary.iteritems()))
  294. def deserialize(self, string):
  295. return dict((piece.split(':', 1) for piece in string.split('|')))