sopel/irc.py
2018-01-18 23:43:27 -05:00

372 lines
11 KiB
Python
Executable File

#! /usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Core IRC functionality. Support for Python 2 is largely gone.
"""
import sys
import os
import time
import socket
import codecs
import traceback
import threading
from datetime import datetime
import errno
import ssl
import asyncore
import asynchat
from logger import get_logger
from tools import stderr, Identifier
from trigger import PreTrigger
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
# 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. """
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 + str(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 isinstance(string, bytes):
string = string.decode('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:
self.send = self._ssl_send
self.recv = self._ssl_recv
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:
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 str.
try:
data = str(data, encoding='utf-8')
except strDecodeError:
# not str, let's try cp1252
try:
data = str(data, encoding='cp1252')
except strDecodeError:
# Okay, let's try ISO8859-1
try:
data = str(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.say(signature, trigger.sender)
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.say("Got an error.", trigger.sender)
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