sopel.irc.Bot.dispatch()   A
last analyzed

Complexity

Conditions 1

Size

Total Lines 2
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 2
dl 0
loc 2
rs 10
c 0
b 0
f 0
cc 1
nop 2
1
# coding=utf-8
2
# irc.py - An Utility IRC Bot
3
# Copyright 2008, Sean B. Palmer, inamidst.com
4
# Copyright 2012, Elsie Powell, http://embolalia.com
5
# Copyright © 2012, Elad Alfassa <[email protected]>
6
#
7
# Licensed under the Eiffel Forum License 2.
8
#
9
# When working on core IRC protocol related features, consult protocol
10
# documentation at http://www.irchelp.org/irchelp/rfc/
11
from __future__ import unicode_literals, absolute_import, print_function, division
12
13
import sys
14
import time
15
import socket
16
import asyncore
17
import asynchat
18
import os
19
import logging
20
from sopel.tools import Identifier
21
from sopel.trigger import PreTrigger
22
try:
23
    import ssl
24
    if not hasattr(ssl, 'match_hostname'):
25
        # Attempt to import ssl_match_hostname from python-backports
26
        import backports.ssl_match_hostname
27
        ssl.match_hostname = backports.ssl_match_hostname.match_hostname
28
        ssl.CertificateError = backports.ssl_match_hostname.CertificateError
29
    has_ssl = True
30
except ImportError:
31
    # no SSL support
32
    has_ssl = False
33
34
import errno
35
import threading
36
from datetime import datetime
37
if sys.version_info.major >= 3:
38
    unicode = str
39
40
41
__all__ = ['Bot']
42
43
LOGGER = logging.getLogger(__name__)
44
45
46
class Bot(asynchat.async_chat):
47
    def __init__(self, config):
48
        ca_certs = config.core.ca_certs
49
50
        asynchat.async_chat.__init__(self)
51
        self.set_terminator(b'\n')
52
        self.buffer = ''
53
54
        self.nick = Identifier(config.core.nick)
55
        """Sopel's current ``Identifier``. Changing this while Sopel is running is
56
        untested."""
57
        self.user = config.core.user
58
        """Sopel's user/ident."""
59
        self.name = config.core.name
60
        """Sopel's "real name", as used for whois."""
61
62
        self.stack = {}
63
        self.ca_certs = ca_certs
64
        self.enabled_capabilities = set()
65
        self.hasquit = False
66
        self.wantsrestart = False
67
68
        self.sending = threading.RLock()
69
        self.writing_lock = threading.Lock()
70
        self.raw = None
71
72
        # Right now, only accounting for two op levels.
73
        # This might be expanded later.
74
        # These lists are filled in startup.py, as of right now.
75
        # Are these even touched at all anymore? Remove in 7.0.
76
        self.ops = dict()
77
        """Deprecated. Use bot.channels instead."""
78
        self.halfplus = dict()
79
        """Deprecated. Use bot.channels instead."""
80
        self.voices = dict()
81
        """Deprecated. Use bot.channels instead."""
82
83
        # We need this to prevent error loops in handle_error
84
        self.error_count = 0
85
86
        self.connection_registered = False
87
        """ Set to True when a server has accepted the client connection and
88
        messages can be sent and received. """
89
90
        # Work around bot.connecting missing in Python older than 2.7.4
91
        if not hasattr(self, "connecting"):
92
            self.connecting = False
93
94
    def log_raw(self, line, prefix):
95
        """Log raw line to the raw log."""
96
        if not self.config.core.log_raw:
97
            return
98
        logger = logging.getLogger('sopel.raw')
99
        logger.info('\t'.join([prefix, line.strip()]))
100
101
    def safe(self, string):
102
        """Remove newlines from a string."""
103
        if sys.version_info.major >= 3 and isinstance(string, bytes):
104
            string = string.decode("utf8")
105
        elif sys.version_info.major < 3:
106
            if not isinstance(string, unicode):
0 ignored issues
show
introduced by
The variable unicode does not seem to be defined in case sys.version_info.major >= 3 on line 37 is False. Are you sure this can never be the case?
Loading history...
107
                string = unicode(string, encoding='utf8')
108
        string = string.replace('\n', '')
109
        string = string.replace('\r', '')
110
        return string
111
112
    def write(self, args, text=None):
113
        args = [self.safe(arg) for arg in args]
114
        if text is not None:
115
            text = self.safe(text)
116
        try:
117
            # Blocking lock, can't send two things at a time
118
            self.writing_lock.acquire()
119
120
            # From RFC2812 Internet Relay Chat: Client Protocol
121
            # Section 2.3
122
            #
123
            # https://tools.ietf.org/html/rfc2812.html
124
            #
125
            # IRC messages are always lines of characters terminated with a
126
            # CR-LF (Carriage Return - Line Feed) pair, and these messages SHALL
127
            # NOT exceed 512 characters in length, counting all characters
128
            # including the trailing CR-LF. Thus, there are 510 characters
129
            # maximum allowed for the command and its parameters. There is no
130
            # provision for continuation of message lines.
131
132
            max_length = unicode_max_length = 510
133
            if text is not None:
134
                temp = (' '.join(args) + ' :' + text)
135
            else:
136
                temp = ' '.join(args)
137
138
            # The max length of 512 is in bytes, not unicode
139
            while len(temp.encode('utf-8')) > max_length:
140
                temp = temp[:unicode_max_length]
141
                unicode_max_length = unicode_max_length - 1
142
143
            # Ends the message with CR-LF
144
            temp = temp + '\r\n'
145
146
            # Log and output the message
147
            self.log_raw(temp, '>>')
148
            self.send(temp.encode('utf-8'))
149
        finally:
150
            self.writing_lock.release()
151
152
        # Simulate echo-message
153
        if ('echo-message' not in self.enabled_capabilities and
154
                args[0].upper() in ['PRIVMSG', 'NOTICE']):
155
            # Use the hostmask we think the IRC server is using for us,
156
            # or something reasonable if that's not available
157
            host = 'localhost'
158
            if self.config.core.bind_host:
159
                host = self.config.core.bind_host
160
            else:
161
                try:
162
                    host = self.hostmask
163
                except KeyError:
164
                    pass  # we tried, and that's good enough
165
166
            pretrigger = PreTrigger(
167
                self.nick,
168
                ":{0}!{1}@{2} {3}".format(self.nick, self.user, host, temp)
0 ignored issues
show
introduced by
The variable temp does not seem to be defined for all execution paths.
Loading history...
169
            )
170
            self.dispatch(pretrigger)
171
172
    def run(self, host, port=6667):
173
        try:
174
            self.initiate_connect(host, port)
175
        except socket.error as e:
176
            LOGGER.exception('Connection error: %s', e)
177
            self.handle_close()
178
179
    def initiate_connect(self, host, port):
180
        LOGGER.info('Connecting to %s:%s...', host, port)
181
        source_address = ((self.config.core.bind_host, 0)
182
                          if self.config.core.bind_host else None)
183
        self.set_socket(socket.create_connection((host, port),
184
                        source_address=source_address))
185
        if self.config.core.use_ssl and has_ssl:
186
            self.send = self._ssl_send
187
            self.recv = self._ssl_recv
188
        elif not has_ssl and self.config.core.use_ssl:
189
            LOGGER.warning(
190
                'SSL is not available on your system; '
191
                'attempting connection without it')
192
        self.connect((host, port))
193
        try:
194
            asyncore.loop()
195
        except KeyboardInterrupt:
196
            LOGGER.warning('KeyboardInterrupt')
197
            self.quit('KeyboardInterrupt')
198
199
    def restart(self, message):
200
        """Disconnect from IRC and restart the bot."""
201
        self.write(['QUIT'], message)
202
        self.wantsrestart = True
203
        self.hasquit = True
204
205
    def quit(self, message):
206
        """Disconnect from IRC and close the bot."""
207
        if self.connected:  # Only send QUIT message if socket is open
208
            self.write(['QUIT'], message)
209
        self.hasquit = True
210
        # Wait for acknowledgement from the server. By RFC 2812 it should be
211
        # an ERROR msg, but many servers just close the connection. Either way
212
        # is fine by us.
213
        # Closing the connection now would mean that stuff in the buffers that
214
        # has not yet been processed would never be processed. It would also
215
        # release the main thread, which is problematic because whomever called
216
        # quit might still want to do something before main thread quits.
217
218
    def handle_close(self):
219
        self.connection_registered = False
220
221
        if hasattr(self, '_shutdown'):
222
            self._shutdown()
223
224
        # This will eventually call asyncore dispatchers close method, which
225
        # will release the main thread. This should be called last to avoid
226
        # race conditions.
227
        if self.socket:
228
            LOGGER.debug('Closing socket')
229
            self.close()
230
231
        LOGGER.info('Closed!')
232
233
    def handle_connect(self):
234
        """
235
        Connect to IRC server, handle TLS and authenticate
236
        user if an account exists.
237
        """
238
        # handle potential TLS connection
239
        if self.config.core.use_ssl and has_ssl:
240
            if not self.config.core.verify_ssl:
241
                self.ssl = ssl.wrap_socket(self.socket,
242
                                           do_handshake_on_connect=True,
243
                                           suppress_ragged_eofs=True)
244
            else:
245
                self.ssl = ssl.wrap_socket(self.socket,
246
                                           do_handshake_on_connect=True,
247
                                           suppress_ragged_eofs=True,
248
                                           cert_reqs=ssl.CERT_REQUIRED,
249
                                           ca_certs=self.ca_certs)
250
                # connect to host specified in config first
251
                try:
252
                    ssl.match_hostname(self.ssl.getpeercert(), self.config.core.host)
253
                except ssl.CertificateError:
254
                    # the host in config and certificate don't match
255
                    LOGGER.error("hostname mismatch between configuration and certificate")
256
                    # check (via exception) if a CNAME matches as a fallback
257
                    has_matched = False
258
                    for hostname in self._get_cnames(self.config.core.host):
259
                        try:
260
                            ssl.match_hostname(self.ssl.getpeercert(), hostname)
261
                            LOGGER.warning("using {0} instead of {1} for TLS connection"
262
                                           .format(hostname, self.config.core.host))
263
                            has_matched = True
264
                            break
265
                        except ssl.CertificateError:
266
                            pass
267
                    if not has_matched:
268
                        # everything is broken
269
                        LOGGER.error("invalid certificate, no hostname matches")
270
                        if hasattr(self.config.core, 'pid_file_path'):
271
                            os.unlink(self.config.core.pid_file_path)
272
                            os._exit(1)
273
            self.set_socket(self.ssl)
274
275
        # Request list of server capabilities. IRCv3 servers will respond with
276
        # CAP * LS (which we handle in coretasks). v2 servers will respond with
277
        # 421 Unknown command, which we'll ignore
278
        self.write(('CAP', 'LS', '302'))
279
280
        # authenticate account if needed
281
        if self.config.core.auth_method == 'server':
282
            self.write(('PASS', self.config.core.auth_password))
283
        elif self.config.core.server_auth_method == 'server':
284
            self.write(('PASS', self.config.core.server_auth_password))
285
        self.write(('NICK', self.nick))
286
        self.write(('USER', self.user, '+iw', self.nick), self.name)
287
288
        # maintain connection
289
        LOGGER.info('Connected.')
290
        self.last_ping_time = datetime.now()
291
        timeout_check_thread = threading.Thread(target=self._timeout_check)
292
        timeout_check_thread.daemon = True
293
        timeout_check_thread.start()
294
        ping_thread = threading.Thread(target=self._send_ping)
295
        ping_thread.daemon = True
296
        ping_thread.start()
297
298
    def _get_cnames(self, domain):
299
        """
300
        Determine the CNAMEs for a given domain.
301
302
        :param domain: domain to check
303
        :type domain: str
304
        :returns: list (of str)
305
        """
306
        import dns.resolver
307
        cnames = []
308
        try:
309
            answer = dns.resolver.query(domain, "CNAME")
310
        except dns.resolver.NoAnswer:
311
            return []
312
        for data in answer:
313
            if isinstance(data, dns.rdtypes.ANY.CNAME.CNAME):
314
                cname = data.to_text()[:-1]
315
                cnames.append(cname)
316
        return cnames
317
318
    def _timeout_check(self):
319
        while self.connected or self.connecting:
320
            if (datetime.now() - self.last_ping_time).seconds > int(self.config.core.timeout):
321
                LOGGER.warning(
322
                    'Ping timeout reached after %s seconds; '
323
                    'closing connection',
324
                    self.config.core.timeout)
325
                self.handle_close()
326
                break
327
            else:
328
                time.sleep(int(self.config.core.timeout))
329
330
    def _send_ping(self):
331
        while self.connected or self.connecting:
332
            if self.connected and (datetime.now() - self.last_ping_time).seconds > int(self.config.core.timeout) / 2:
333
                try:
334
                    self.write(('PING', self.config.core.host))
335
                except socket.error:
336
                    pass
337
            time.sleep(int(self.config.core.timeout) / 2)
338
339
    def _ssl_send(self, data):
340
        """Replacement for self.send() during SSL connections."""
341
        try:
342
            result = self.socket.send(data)
343
            return result
344
        except ssl.SSLError as why:
345
            if why[0] in (asyncore.EWOULDBLOCK, errno.ESRCH):
346
                return 0
347
            else:
348
                raise why
349
            return 0
350
351
    def _ssl_recv(self, buffer_size):
352
        """Replacement for self.recv() during SSL connections.
353
354
        From: http://evanfosmark.com/2010/09/ssl-support-in-asynchatasync_chat
355
356
        """
357
        try:
358
            data = self.socket.read(buffer_size)
359
            if not data:
360
                self.handle_close()
361
                return b''
362
            return data
363
        except ssl.SSLError as why:
364
            if why[0] in (asyncore.ECONNRESET, asyncore.ENOTCONN,
365
                          asyncore.ESHUTDOWN):
366
                self.handle_close()
367
                return ''
368
            elif why[0] == errno.ENOENT:
369
                # Required in order to keep it non-blocking
370
                return b''
371
            else:
372
                raise
373
374
    def collect_incoming_data(self, data):
375
        # We can't trust clients to pass valid unicode.
376
        try:
377
            data = unicode(data, encoding='utf-8')
0 ignored issues
show
introduced by
The variable unicode does not seem to be defined in case sys.version_info.major >= 3 on line 37 is False. Are you sure this can never be the case?
Loading history...
378
        except UnicodeDecodeError:
379
            # not unicode, let's try cp1252
380
            try:
381
                data = unicode(data, encoding='cp1252')
382
            except UnicodeDecodeError:
383
                # Okay, let's try ISO8859-1
384
                try:
385
                    data = unicode(data, encoding='iso8859-1')
386
                except UnicodeDecodeError:
387
                    # Discard line if encoding is unknown
388
                    return
389
390
        if data:
391
            self.log_raw(data, '<<')
392
        self.buffer += data
393
394
    def found_terminator(self):
395
        line = self.buffer
396
        if line.endswith('\r'):
397
            line = line[:-1]
398
        self.buffer = ''
399
        self.last_ping_time = datetime.now()
400
        pretrigger = PreTrigger(self.nick, line)
401
        if all(cap not in self.enabled_capabilities for cap in ['account-tag', 'extended-join']):
402
            pretrigger.tags.pop('account', None)
403
404
        if pretrigger.event == 'PING':
405
            self.write(('PONG', pretrigger.args[-1]))
406
        elif pretrigger.event == 'ERROR':
407
            LOGGER.error("ERROR received from server: %s", pretrigger.args[-1])
408
            if self.hasquit:
409
                self.close_when_done()
410
        elif pretrigger.event == '433':
411
            LOGGER.error('Nickname already in use!')
412
            self.handle_close()
413
414
        self.dispatch(pretrigger)
415
416
    def dispatch(self, pretrigger):
417
        pass
418
419
    def error(self, trigger=None, exception=None):
420
        """Called internally when a module causes an error."""
421
        message = 'Unexpected error'
422
        if exception:
423
            message = '{} ({})'.format(message, exception)
424
425
        if trigger:
426
            message = '{} from {} at {}. Message was: {}'.format(
427
                message, trigger.nick, str(datetime.now()), trigger.group(0)
428
            )
429
430
        LOGGER.exception(message)
431
432
        if trigger and self.config.core.reply_errors and trigger.sender is not None:
433
            self.say(message, trigger.sender)
434
435
    def handle_error(self):
436
        """Handle any uncaptured error in the core.
437
438
        This method is an override of :meth:`asyncore.dispatcher.handle_error`,
439
        the :class:`asynchat.async_chat` being a subclass of
440
        :class:`asyncore.dispatcher`.
441
        """
442
        LOGGER.error('Fatal error in core; please review exception log.')
443
444
        err_log = logging.getLogger('sopel.exceptions')
445
        err_log.error(
446
            'Fatal error in core; handle_error() was called.\n'
447
            'Last raw line was: %s\n'
448
            'Buffer:\n%s\n',
449
            self.raw, self.buffer
450
        )
451
        err_log.exception('Fatal error traceback')
452
        err_log.error('----------------------------------------')
453
454
        if self.error_count > 10:
455
            # quit if too many errors
456
            if (datetime.now() - self.last_error_timestamp).seconds < 5:
457
                LOGGER.error("Too many errors; can't continue")
458
                os._exit(1)
459
            # TODO: should we reset error_count?
460
461
        self.last_error_timestamp = datetime.now()
462
        self.error_count = self.error_count + 1
463