# coding=utf-8 # irc.py - An Utility IRC Bot # Copyright 2008, Sean B. Palmer, inamidst.com # Copyright 2012, Elsie Powell, http://embolalia.com # Copyright © 2012, Elad Alfassa # # Licensed under the Eiffel Forum License 2. # # When working on core IRC protocol related features, consult protocol # documentation at http://www.irchelp.org/irchelp/rfc/ from __future__ import unicode_literals, absolute_import, print_function, division import sys import time import socket import asyncore import asynchat import os import codecs import traceback from logger import get_logger from tools import stderr, Identifier from trigger import PreTrigger try: import ssl if not hasattr(ssl, 'match_hostname'): # Attempt to import ssl_match_hostname from python-backports import backports.ssl_match_hostname ssl.match_hostname = backports.ssl_match_hostname.match_hostname ssl.CertificateError = backports.ssl_match_hostname.CertificateError has_ssl = True except ImportError: # no SSL support has_ssl = False import errno import threading from datetime import datetime if sys.version_info.major >= 3: unicode = str LOGGER = get_logger(__name__) class Bot(asynchat.async_chat): def __init__(self, config): ca_certs = config.core.ca_certs asynchat.async_chat.__init__(self) self.set_terminator(b'\n') self.buffer = '' self.nick = Identifier(config.core.nick) """Sopel's current ``Identifier``. Changing this while Sopel is running is untested.""" self.user = config.core.user """Sopel's user/ident.""" self.name = config.core.name """Sopel's "real name", as used for whois.""" self.stack = {} self.ca_certs = ca_certs self.enabled_capabilities = set() self.hasquit = False self.sending = threading.RLock() self.writing_lock = threading.Lock() self.raw = None # Right now, only accounting for two op levels. # This might be expanded later. # These lists are filled in startup.py, as of right now. # Are these even touched at all anymore? Remove in 7.0. self.ops = dict() """Deprecated. Use bot.channels instead.""" self.halfplus = dict() """Deprecated. Use bot.channels instead.""" self.voices = dict() """Deprecated. Use bot.channels instead.""" # We need this to prevent error loops in handle_error self.error_count = 0 self.connection_registered = False """ Set to True when a server has accepted the client connection and messages can be sent and received. """ # Work around bot.connecting missing in Python older than 2.7.4 if not hasattr(self, "connecting"): self.connecting = False def log_raw(self, line, prefix): """Log raw line to the raw log.""" if not self.config.core.log_raw: return if not os.path.isdir(self.config.core.logdir): try: os.mkdir(self.config.core.logdir) except Exception as e: stderr('There was a problem creating the logs directory.') stderr('%s %s' % (str(e.__class__), str(e))) stderr('Please fix this and then run Sopel again.') os._exit(1) f = codecs.open(os.path.join(self.config.core.logdir, 'raw.log'), 'a', encoding='utf-8') f.write(prefix + unicode(time.time()) + "\t") temp = line.replace('\n', '') f.write(temp) f.write("\n") f.close() def safe(self, string): """Remove newlines from a string.""" if sys.version_info.major >= 3 and isinstance(string, bytes): string = string.decode('utf8') elif sys.version_info.major < 3: if not isinstance(string, unicode): string = unicode(string, encoding='utf8') string = string.replace('\n', '') string = string.replace('\r', '') return string def write(self, args, text=None): args = [self.safe(arg) for arg in args] if text is not None: text = self.safe(text) try: self.writing_lock.acquire() # Blocking lock, can't send two things # at a time # From RFC2812 Internet Relay Chat: Client Protocol # Section 2.3 # # https://tools.ietf.org/html/rfc2812.html # # IRC messages are always lines of characters terminated with a # CR-LF (Carriage Return - Line Feed) pair, and these messages SHALL # NOT exceed 512 characters in length, counting all characters # including the trailing CR-LF. Thus, there are 510 characters # maximum allowed for the command and its parameters. There is no # provision for continuation of message lines. if text is not None: temp = (' '.join(args) + ' :' + text)[:510] + '\r\n' else: temp = ' '.join(args)[:510] + '\r\n' self.log_raw(temp, '>>') self.send(temp.encode('utf-8')) finally: self.writing_lock.release() def run(self, host, port=6667): try: self.initiate_connect(host, port) except socket.error as e: stderr('Connection error: %s' % e) def initiate_connect(self, host, port): stderr('Connecting to %s:%s...' % (host, port)) source_address = ((self.config.core.bind_host, 0) if self.config.core.bind_host else None) self.set_socket(socket.create_connection((host, port), source_address=source_address)) if self.config.core.use_ssl and has_ssl: self.send = self._ssl_send self.recv = self._ssl_recv elif not has_ssl and self.config.core.use_ssl: stderr('SSL is not avilable on your system, attempting connection ' 'without it') self.connect((host, port)) try: asyncore.loop() except KeyboardInterrupt: print('KeyboardInterrupt') self.quit('KeyboardInterrupt') def quit(self, message): """Disconnect from IRC and close the bot.""" self.write(['QUIT'], message) self.hasquit = True # Wait for acknowledgement from the server. By RFC 2812 it should be # an ERROR msg, but many servers just close the connection. Either way # is fine by us. # Closing the connection now would mean that stuff in the buffers that # has not yet been processed would never be processed. It would also # release the main thread, which is problematic because whomever called # quit might still want to do something before main thread quits. def handle_close(self): self.connection_registered = False if hasattr(self, '_shutdown'): self._shutdown() stderr('Closed!') # This will eventually call asyncore dispatchers close method, which # will release the main thread. This should be called last to avoid # race conditions. self.close() def handle_connect(self): if self.config.core.use_ssl and has_ssl: if not self.config.core.verify_ssl: self.ssl = ssl.wrap_socket(self.socket, do_handshake_on_connect=True, suppress_ragged_eofs=True) else: self.ssl = ssl.wrap_socket(self.socket, do_handshake_on_connect=True, suppress_ragged_eofs=True, cert_reqs=ssl.CERT_REQUIRED, ca_certs=self.ca_certs) try: ssl.match_hostname(self.ssl.getpeercert(), self.config.core.host) except ssl.CertificateError: stderr("Invalid certficate, hostname mismatch!") os.unlink(self.config.core.pid_file_path) os._exit(1) self.set_socket(self.ssl) # Request list of server capabilities. IRCv3 servers will respond with # CAP * LS (which we handle in coretasks). v2 servers will respond with # 421 Unknown command, which we'll ignore self.write(('CAP', 'LS', '302')) if self.config.core.auth_method == 'server': password = self.config.core.auth_password self.write(('PASS', password)) self.write(('NICK', self.nick)) self.write(('USER', self.user, '+iw', self.nick), self.name) stderr('Connected.') self.last_ping_time = datetime.now() timeout_check_thread = threading.Thread(target=self._timeout_check) timeout_check_thread.daemon = True timeout_check_thread.start() ping_thread = threading.Thread(target=self._send_ping) ping_thread.daemon = True ping_thread.start() def _timeout_check(self): while self.connected or self.connecting: if (datetime.now() - self.last_ping_time).seconds > int(self.config.core.timeout): stderr('Ping timeout reached after %s seconds, closing connection' % self.config.core.timeout) self.handle_close() break else: time.sleep(int(self.config.core.timeout)) def _send_ping(self): while self.connected or self.connecting: if self.connected and (datetime.now() - self.last_ping_time).seconds > int(self.config.core.timeout) / 2: try: self.write(('PING', self.config.core.host)) except socket.error: pass time.sleep(int(self.config.core.timeout) / 2) def _ssl_send(self, data): """Replacement for self.send() during SSL connections.""" try: result = self.socket.send(data) return result except ssl.SSLError as why: if why[0] in (asyncore.EWOULDBLOCK, errno.ESRCH): return 0 else: raise why return 0 def _ssl_recv(self, buffer_size): """Replacement for self.recv() during SSL connections. From: http://evanfosmark.com/2010/09/ssl-support-in-asynchatasync_chat """ try: data = self.socket.read(buffer_size) if not data: self.handle_close() return b'' return data except ssl.SSLError as why: if why[0] in (asyncore.ECONNRESET, asyncore.ENOTCONN, asyncore.ESHUTDOWN): self.handle_close() return '' elif why[0] == errno.ENOENT: # Required in order to keep it non-blocking return b'' else: raise def collect_incoming_data(self, data): # We can't trust clients to pass valid unicode. try: data = unicode(data, encoding='utf-8') except UnicodeDecodeError: # not unicode, let's try cp1252 try: data = unicode(data, encoding='cp1252') except UnicodeDecodeError: # Okay, let's try ISO8859-1 try: data = unicode(data, encoding='iso8859-1') except: # Discard line if encoding is unknown return if data: self.log_raw(data, '<<') self.buffer += data def found_terminator(self): line = self.buffer if line.endswith('\r'): line = line[:-1] self.buffer = '' self.last_ping_time = datetime.now() pretrigger = PreTrigger(self.nick, line) if all(cap not in self.enabled_capabilities for cap in ['account-tag', 'extended-join']): pretrigger.tags.pop('account', None) if pretrigger.event == 'PING': self.write(('PONG', pretrigger.args[-1])) elif pretrigger.event == 'ERROR': LOGGER.error("ERROR recieved from server: %s", pretrigger.args[-1]) if self.hasquit: self.close_when_done() elif pretrigger.event == '433': stderr('Nickname already in use!') self.handle_close() self.dispatch(pretrigger) def dispatch(self, pretrigger): pass def error(self, trigger=None): """Called internally when a module causes an error.""" try: trace = traceback.format_exc() if sys.version_info.major < 3: trace = trace.decode('utf-8', errors='xmlcharrefreplace') stderr(trace) try: lines = list(reversed(trace.splitlines())) report = [lines[0].strip()] for line in lines: line = line.strip() if line.startswith('File "'): report.append(line[0].lower() + line[1:]) break else: report.append('source unknown') signature = '%s (%s)' % (report[0], report[1]) # TODO: make not hardcoded log_filename = os.path.join(self.config.core.logdir, 'exceptions.log') with codecs.open(log_filename, 'a', encoding='utf-8') as logfile: logfile.write('Signature: %s\n' % signature) if trigger: logfile.write('from {} at {}. Message was: {}\n'.format( trigger.nick, str(datetime.now()), trigger.group(0))) logfile.write(trace) logfile.write( '----------------------------------------\n\n' ) except Exception as e: stderr("Could not save full traceback!") LOGGER.error("Could not save traceback from %s to file: %s", trigger.sender, str(e)) if trigger and self.config.core.reply_errors and trigger.sender is not None: self.msg(trigger.sender, signature) if trigger: LOGGER.error('Exception from {}: {} ({})'.format(trigger.sender, str(signature), trigger.raw)) except Exception as e: if trigger and self.config.core.reply_errors and trigger.sender is not None: self.msg(trigger.sender, "Got an error.") if trigger: LOGGER.error('Exception from {}: {} ({})'.format(trigger.sender, str(e), trigger.raw)) def handle_error(self): """Handle any uncaptured error in the core. Overrides asyncore's handle_error. """ trace = traceback.format_exc() stderr(trace) LOGGER.error('Fatal error in core, please review exception log') # TODO: make not hardcoded logfile = codecs.open( os.path.join(self.config.core.logdir, 'exceptions.log'), 'a', encoding='utf-8' ) logfile.write('Fatal error in core, handle_error() was called\n') logfile.write('last raw line was %s' % self.raw) logfile.write(trace) logfile.write('Buffer:\n') logfile.write(self.buffer) logfile.write('----------------------------------------\n\n') logfile.close() if self.error_count > 10: if (datetime.now() - self.last_error_timestamp).seconds < 5: stderr("Too many errors, can't continue") os._exit(1) self.last_error_timestamp = datetime.now() self.error_count = self.error_count + 1