sopel.coretasks._record_who()   C
last analyzed

Complexity

Conditions 11

Size

Total Lines 35
Code Lines 31

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 31
dl 0
loc 35
rs 5.4
c 0
b 0
f 0
cc 11
nop 8

How to fix   Complexity    Many Parameters   

Complexity

Complex classes like sopel.coretasks._record_who() often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

Many Parameters

Methods with many parameters are not only hard to understand, but their parameters also often become inconsistent when you need more, or different data.

There are several approaches to avoid long parameter lists:

1
# coding=utf-8
2
"""Tasks that allow the bot to run, but aren't user-facing functionality
3
4
This is written as a module to make it easier to extend to support more
5
responses to standard IRC codes without having to shove them all into the
6
dispatch function in bot.py and making it easier to maintain.
7
"""
8
# Copyright 2008-2011, Sean B. Palmer (inamidst.com) and Michael Yanovich
9
# (yanovich.net)
10
# Copyright © 2012, Elad Alfassa <[email protected]>
11
# Copyright 2012-2015, Elsie Powell embolalia.com
12
# Licensed under the Eiffel Forum License 2.
13
from __future__ import unicode_literals, absolute_import, print_function, division
14
15
import logging
16
from random import randint
17
import datetime
18
import re
19
import sys
20
import time
21
import sopel
22
import sopel.module
23
import sopel.tools.web
24
from sopel.bot import _CapReq
25
from sopel.tools import Identifier, iteritems, events
26
from sopel.tools.target import User, Channel
27
import base64
28
29
if sys.version_info.major >= 3:
30
    unicode = str
31
32
LOGGER = logging.getLogger(__name__)
33
34
batched_caps = {}
35
who_reqs = {}  # Keeps track of reqs coming from this module, rather than others
36
37
38
def auth_after_register(bot):
39
    """Do NickServ/AuthServ auth"""
40
    if bot.config.core.auth_method:
41
        auth_method = bot.config.core.auth_method
42
        auth_username = bot.config.core.auth_username
43
        auth_password = bot.config.core.auth_password
44
        auth_target = bot.config.core.auth_target
45
    elif bot.config.core.nick_auth_method:
46
        auth_method = bot.config.core.nick_auth_method
47
        auth_username = (bot.config.core.nick_auth_username or
48
                         bot.config.core.nick)
49
        auth_password = bot.config.core.nick_auth_password
50
        auth_target = bot.config.core.nick_auth_target
51
    else:
52
        return
53
54
    if auth_method == 'nickserv':
55
        bot.say('IDENTIFY %s' % auth_password, auth_target or 'NickServ')
56
    elif auth_method == 'authserv':
57
        bot.write(('AUTHSERV auth', auth_username + ' ' + auth_password))
58
    elif auth_method == 'Q':
59
        bot.write(('AUTH', auth_username + ' ' + auth_password))
60
    elif auth_method == 'userserv':
61
        bot.say("LOGIN %s %s" % (auth_username, auth_password),
62
                auth_target or 'UserServ')
63
64
65
@sopel.module.event(events.RPL_WELCOME, events.RPL_LUSERCLIENT)
66
@sopel.module.thread(False)
67
@sopel.module.unblockable
68
def startup(bot, trigger):
69
    """Do tasks related to connecting to the network.
70
71
    001 RPL_WELCOME is from RFC2812 and is the first message that is sent after
72
    the connection has been registered on the network.
73
74
    251 RPL_LUSERCLIENT is a mandatory message that is sent after client
75
    connects to the server in rfc1459. RFC2812 does not require it and all
76
    networks might not send it. We support both.
77
78
    """
79
    if bot.connection_registered:
80
        return
81
82
    bot.connection_registered = True
83
84
    auth_after_register(bot)
85
86
    modes = bot.config.core.modes
87
    bot.write(('MODE', '%s +%s' % (bot.nick, modes)))
88
89
    bot.memory['retry_join'] = dict()
90
91
    if bot.config.core.throttle_join:
92
        throttle_rate = int(bot.config.core.throttle_join)
93
        channels_joined = 0
94
        for channel in bot.config.core.channels:
95
            channels_joined += 1
96
            if not channels_joined % throttle_rate:
97
                time.sleep(1)
98
            bot.join(channel)
99
    else:
100
        for channel in bot.config.core.channels:
101
            bot.join(channel)
102
103
    if (not bot.config.core.owner_account and
104
            'account-tag' in bot.enabled_capabilities and
105
            '@' not in bot.config.core.owner):
106
        msg = (
107
            "This network supports using network services to identify you as "
108
            "my owner, rather than just matching your nickname. This is much "
109
            "more secure. If you'd like to do this, make sure you're logged in "
110
            "and reply with \"{}useserviceauth\""
111
        ).format(bot.config.core.help_prefix)
112
        bot.say(msg, bot.config.core.owner)
113
114
115
@sopel.module.require_privmsg()
116
@sopel.module.require_owner()
117
@sopel.module.commands('useserviceauth')
118
def enable_service_auth(bot, trigger):
119
    if bot.config.core.owner_account:
120
        return
121
    if 'account-tag' not in bot.enabled_capabilities:
122
        bot.say('This server does not fully support services auth, so this '
123
                'command is not available.')
124
        return
125
    if not trigger.account:
126
        bot.say('You must be logged in to network services before using this '
127
                'command.')
128
        return
129
    bot.config.core.owner_account = trigger.account
130
    bot.config.save()
131
    bot.say('Success! I will now use network services to identify you as my '
132
            'owner.')
133
134
135
@sopel.module.event(events.ERR_NOCHANMODES)
136
@sopel.module.priority('high')
137
def retry_join(bot, trigger):
138
    """Give NickServer enough time to identify on a +R channel.
139
140
    Give NickServ enough time to identify, and retry rejoining an
141
    identified-only (+R) channel. Maximum of ten rejoin attempts.
142
143
    """
144
    channel = trigger.args[1]
145
    if channel in bot.memory['retry_join'].keys():
146
        bot.memory['retry_join'][channel] += 1
147
        if bot.memory['retry_join'][channel] > 10:
148
            LOGGER.warning('Failed to join %s after 10 attempts.', channel)
149
            return
150
    else:
151
        bot.memory['retry_join'][channel] = 0
152
        bot.join(channel)
153
        return
154
155
    time.sleep(6)
156
    bot.join(channel)
157
158
159
@sopel.module.rule('(.*)')
160
@sopel.module.event(events.RPL_NAMREPLY)
161
@sopel.module.priority('high')
162
@sopel.module.thread(False)
163
@sopel.module.unblockable
164
def handle_names(bot, trigger):
165
    """Handle NAMES response, happens when joining to channels."""
166
    names = trigger.split()
167
168
    # TODO specific to one channel type. See issue 281.
169
    channels = re.search(r'(#\S*)', trigger.raw)
170
    if not channels:
171
        return
172
    channel = Identifier(channels.group(1))
173
    if channel not in bot.privileges:
174
        bot.privileges[channel] = dict()
175
    if channel not in bot.channels:
176
        bot.channels[channel] = Channel(channel)
177
178
    # This could probably be made flexible in the future, but I don't think
179
    # it'd be worth it.
180
    # If this ever needs to be updated, remember to change the mode handling in
181
    # the WHO-handler functions below, too.
182
    mapping = {
183
        "+": sopel.module.VOICE,
184
        "%": sopel.module.HALFOP,
185
        "@": sopel.module.OP,
186
        "&": sopel.module.ADMIN,
187
        "~": sopel.module.OWNER,
188
    }
189
190
    for name in names:
191
        priv = 0
192
        for prefix, value in iteritems(mapping):
193
            if prefix in name:
194
                priv = priv | value
195
        nick = Identifier(name.lstrip(''.join(mapping.keys())))
196
        bot.privileges[channel][nick] = priv
197
        user = bot.users.get(nick)
198
        if user is None:
199
            # It's not possible to set the username/hostname from info received
200
            # in a NAMES reply, unfortunately.
201
            # Fortunately, the user should already exist in bot.users by the
202
            # time this code runs, so this is 99.9% ass-covering.
203
            user = User(nick, None, None)
204
            bot.users[nick] = user
205
        bot.channels[channel].add_user(user, privs=priv)
206
207
208
@sopel.module.rule('(.*)')
209
@sopel.module.event('MODE')
210
@sopel.module.priority('high')
211
@sopel.module.thread(False)
212
@sopel.module.unblockable
213
def track_modes(bot, trigger):
214
    """Track usermode changes and keep our lists of ops up to date."""
215
    # Mode message format: <channel> *( ( "-" / "+" ) *<modes> *<modeparams> )
216
    if len(trigger.args) < 3:
217
        # We need at least [channel, mode, nickname] to do anything useful
218
        # MODE messages with fewer args won't help us
219
        LOGGER.debug("Received an apparently useless MODE message: {}"
220
                     .format(trigger.raw))
221
        return
222
    # Our old MODE parsing code checked if any of the args was empty.
223
    # Somewhere around here would be a good place to re-implement that if it's
224
    # actually necessary to guard against some non-compliant IRCd. But for now
225
    # let's just log malformed lines to the debug log.
226
    if not all(trigger.args):
227
        LOGGER.debug("The server sent a possibly malformed MODE message: {}"
228
                     .format(trigger.raw))
229
230
    # From here on, we will make a (possibly dangerous) assumption that the
231
    # received MODE message is more-or-less compliant
232
    channel = Identifier(trigger.args[0])
233
    # If the first character of where the mode is being set isn't a #
234
    # then it's a user mode, not a channel mode, so we'll ignore it.
235
    # TODO: Handle CHANTYPES from ISUPPORT numeric (005)
236
    # (Actually, most of this function should be rewritten again when we parse
237
    # ISUPPORT...)
238
    if channel.is_nick():
239
        return
240
241
    modestring = trigger.args[1]
242
    nicks = [Identifier(nick) for nick in trigger.args[2:]]
243
244
    mapping = {
245
        "v": sopel.module.VOICE,
246
        "h": sopel.module.HALFOP,
247
        "o": sopel.module.OP,
248
        "a": sopel.module.ADMIN,
249
        "q": sopel.module.OWNER,
250
    }
251
252
    # Parse modes before doing anything else
253
    modes = []
254
    sign = ''
255
    for char in modestring:
256
        # There was a comment claiming IRC allows e.g. MODE +aB-c foo, but it
257
        # doesn't seem to appear in any RFCs. But modern.ircdocs.horse shows
258
        # it, so we'll leave in the extra parsing for now.
259
        if char in '+-':
260
            sign = char
261
        elif char in mapping:
262
            # Filter out unexpected modes and hope they don't have parameters
263
            modes.append(sign + char)
264
265
    # Try to map modes to arguments, after sanity-checking
266
    if len(modes) != len(nicks) or not all([nick.is_nick() for nick in nicks]):
267
        # Something fucky happening, like unusual batching of non-privilege
268
        # modes together with the ones we expect. Way easier to just re-WHO
269
        # than try to account for non-standard parameter-taking modes.
270
        _send_who(bot, channel)
271
        return
272
    pairs = dict(zip(modes, nicks))
273
274
    for (mode, nick) in pairs.items():
275
        priv = bot.channels[channel].privileges.get(nick, 0)
276
        # Log a warning if the two privilege-tracking data structures
277
        # get out of sync. That should never happen.
278
        # This is a good place to verify that bot.channels is doing
279
        # what it's supposed to do before ultimately removing the old,
280
        # deprecated bot.privileges structure completely.
281
        ppriv = bot.privileges[channel].get(nick, 0)
282
        if priv != ppriv:
283
            LOGGER.warning("Privilege data error! Please share Sopel's"
284
                           "raw log with the developers, if enabled. "
285
                           "(Expected {} == {} for {} in {}.)"
286
                           .format(priv, ppriv, nick, channel))
287
        value = mapping.get(mode[1])
288
        if value is not None:
289
            if mode[0] == '+':
290
                priv = priv | value
291
            else:
292
                priv = priv & ~value
293
            bot.privileges[channel][nick] = priv
294
            bot.channels[channel].privileges[nick] = priv
295
296
297
@sopel.module.event('NICK')
298
@sopel.module.priority('high')
299
@sopel.module.thread(False)
300
@sopel.module.unblockable
301
def track_nicks(bot, trigger):
302
    """Track nickname changes and maintain our chanops list accordingly."""
303
    old = trigger.nick
304
    new = Identifier(trigger)
305
306
    # Give debug mssage, and PM the owner, if the bot's own nick changes.
307
    if old == bot.nick and new != bot.nick:
308
        privmsg = (
309
            "Hi, I'm your bot, %s. Something has made my nick change. This "
310
            "can cause some problems for me, and make me do weird things. "
311
            "You'll probably want to restart me, and figure out what made "
312
            "that happen so you can stop it happening again. (Usually, it "
313
            "means you tried to give me a nick that's protected by NickServ.)"
314
        ) % bot.nick
315
        debug_msg = (
316
            "Nick changed by server. This can cause unexpected behavior. "
317
            "Please restart the bot."
318
        )
319
        LOGGER.critical(debug_msg)
320
        bot.say(privmsg, bot.config.core.owner)
321
        return
322
323
    for channel in bot.privileges:
324
        channel = Identifier(channel)
325
        if old in bot.privileges[channel]:
326
            value = bot.privileges[channel].pop(old)
327
            bot.privileges[channel][new] = value
328
329
    for channel in bot.channels.values():
330
        channel.rename_user(old, new)
331
    if old in bot.users:
332
        bot.users[new] = bot.users.pop(old)
333
334
335
@sopel.module.rule('(.*)')
336
@sopel.module.event('PART')
337
@sopel.module.priority('high')
338
@sopel.module.thread(False)
339
@sopel.module.unblockable
340
def track_part(bot, trigger):
341
    nick = trigger.nick
342
    channel = trigger.sender
343
    _remove_from_channel(bot, nick, channel)
344
345
346
@sopel.module.event('KICK')
347
@sopel.module.priority('high')
348
@sopel.module.thread(False)
349
@sopel.module.unblockable
350
def track_kick(bot, trigger):
351
    nick = Identifier(trigger.args[1])
352
    channel = trigger.sender
353
    _remove_from_channel(bot, nick, channel)
354
355
356
def _remove_from_channel(bot, nick, channel):
357
    if nick == bot.nick:
358
        bot.privileges.pop(channel, None)
359
        bot.channels.pop(channel, None)
360
361
        lost_users = []
362
        for nick_, user in bot.users.items():
363
            user.channels.pop(channel, None)
364
            if not user.channels:
365
                lost_users.append(nick_)
366
        for nick_ in lost_users:
367
            bot.users.pop(nick_, None)
368
    else:
369
        bot.privileges[channel].pop(nick, None)
370
371
        user = bot.users.get(nick)
372
        if user and channel in user.channels:
373
            bot.channels[channel].clear_user(nick)
374
            if not user.channels:
375
                bot.users.pop(nick, None)
376
377
378
def _whox_enabled(bot):
379
    # Either privilege tracking or away notification. For simplicity, both
380
    # account notify and extended join must be there for account tracking.
381
    return (('account-notify' in bot.enabled_capabilities and
382
             'extended-join' in bot.enabled_capabilities) or
383
            'away-notify' in bot.enabled_capabilities)
384
385
386
def _send_who(bot, channel):
387
    if _whox_enabled(bot):
388
        # WHOX syntax, see http://faerion.sourceforge.net/doc/irc/whox.var
389
        # Needed for accounts in who replies. The random integer is a param
390
        # to identify the reply as one from this command, because if someone
391
        # else sent it, we have no fucking way to know what the format is.
392
        rand = str(randint(0, 999))
393
        while rand in who_reqs:
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable who_reqs does not seem to be defined.
Loading history...
394
            rand = str(randint(0, 999))
395
        who_reqs[rand] = channel
396
        bot.write(['WHO', channel, 'a%nuachtf,' + rand])
397
    else:
398
        # We might be on an old network, but we still care about keeping our
399
        # user list updated
400
        bot.write(['WHO', channel])
401
    bot.channels[Identifier(channel)].last_who = datetime.datetime.utcnow()
402
403
404
@sopel.module.interval(30)
405
def _periodic_send_who(bot):
406
    """Periodically send a WHO request to keep user information up-to-date."""
407
    if 'away-notify' in bot.enabled_capabilities:
408
        # WHO not needed to update 'away' status
409
        return
410
411
    # Loops through the channels to find the one that has the longest time since the last WHO
412
    # request, and issues a WHO request only if the last request for the channel was more than
413
    # 120 seconds ago.
414
    who_trigger_time = datetime.datetime.utcnow() - datetime.timedelta(seconds=120)
415
    selected_channel = None
416
    for channel_name, channel in bot.channels.items():
417
        if channel.last_who is None:
418
            # WHO was never sent yet to this channel: stop here
419
            selected_channel = channel_name
420
            break
421
        if channel.last_who < who_trigger_time:
422
            # this channel's last who request is the most outdated one at the moment
423
            selected_channel = channel_name
424
            who_trigger_time = channel.last_who
425
426
    if selected_channel is not None:
427
        # selected_channel's last who is either none or the oldest valid
428
        _send_who(bot, selected_channel)
429
430
431
@sopel.module.event('JOIN')
432
@sopel.module.priority('high')
433
@sopel.module.thread(False)
434
@sopel.module.unblockable
435
def track_join(bot, trigger):
436
    if trigger.nick == bot.nick and trigger.sender not in bot.channels:
437
        bot.write(('TOPIC', trigger.sender))
438
439
        bot.privileges[trigger.sender] = dict()
440
        bot.channels[trigger.sender] = Channel(trigger.sender)
441
        _send_who(bot, trigger.sender)
442
443
    bot.privileges[trigger.sender][trigger.nick] = 0
444
445
    user = bot.users.get(trigger.nick)
446
    if user is None:
447
        user = User(trigger.nick, trigger.user, trigger.host)
448
        bot.users[trigger.nick] = user
449
    bot.channels[trigger.sender].add_user(user)
450
451
    if len(trigger.args) > 1 and trigger.args[1] != '*' and (
452
            'account-notify' in bot.enabled_capabilities and
453
            'extended-join' in bot.enabled_capabilities):
454
        user.account = trigger.args[1]
455
456
457
@sopel.module.event('QUIT')
458
@sopel.module.priority('high')
459
@sopel.module.thread(False)
460
@sopel.module.unblockable
461
def track_quit(bot, trigger):
462
    for chanprivs in bot.privileges.values():
463
        chanprivs.pop(trigger.nick, None)
464
    for channel in bot.channels.values():
465
        channel.clear_user(trigger.nick)
466
    bot.users.pop(trigger.nick, None)
467
468
469
@sopel.module.event('CAP')
470
@sopel.module.thread(False)
471
@sopel.module.priority('high')
472
@sopel.module.unblockable
473
def receive_cap_list(bot, trigger):
474
    cap = trigger.strip('-=~')
475
    # Server is listing capabilites
476
    if trigger.args[1] == 'LS':
477
        receive_cap_ls_reply(bot, trigger)
478
    # Server denied CAP REQ
479
    elif trigger.args[1] == 'NAK':
480
        entry = bot._cap_reqs.get(cap, None)
481
        # If it was requested with bot.cap_req
482
        if entry:
483
            for req in entry:
484
                # And that request was mandatory/prohibit, and a callback was
485
                # provided
486
                if req.prefix and req.failure:
487
                    # Call it.
488
                    req.failure(bot, req.prefix + cap)
489
    # Server is removing a capability
490
    elif trigger.args[1] == 'DEL':
491
        entry = bot._cap_reqs.get(cap, None)
492
        # If it was requested with bot.cap_req
493
        if entry:
494
            for req in entry:
495
                # And that request wasn't prohibit, and a callback was
496
                # provided
497
                if req.prefix != '-' and req.failure:
498
                    # Call it.
499
                    req.failure(bot, req.prefix + cap)
500
    # Server is adding new capability
501
    elif trigger.args[1] == 'NEW':
502
        entry = bot._cap_reqs.get(cap, None)
503
        # If it was requested with bot.cap_req
504
        if entry:
505
            for req in entry:
506
                # And that request wasn't prohibit
507
                if req.prefix != '-':
508
                    # Request it
509
                    bot.write(('CAP', 'REQ', req.prefix + cap))
510
    # Server is acknowledging a capability
511
    elif trigger.args[1] == 'ACK':
512
        caps = trigger.args[2].split()
513
        for cap in caps:
514
            cap.strip('-~= ')
515
            bot.enabled_capabilities.add(cap)
516
            entry = bot._cap_reqs.get(cap, [])
517
            for req in entry:
518
                if req.success:
519
                    req.success(bot, req.prefix + trigger)
520
            if cap == 'sasl':  # TODO why is this not done with bot.cap_req?
521
                receive_cap_ack_sasl(bot)
522
523
524
def receive_cap_ls_reply(bot, trigger):
525
    if bot.server_capabilities:
526
        # We've already seen the results, so someone sent CAP LS from a module.
527
        # We're too late to do SASL, and we don't want to send CAP END before
528
        # the module has done what it needs to, so just return
529
        return
530
531
    for cap in trigger.split():
532
        c = cap.split('=')
533
        if len(c) == 2:
534
            batched_caps[c[0]] = c[1]
535
        else:
536
            batched_caps[c[0]] = None
537
538
    # Not the last in a multi-line reply. First two args are * and LS.
539
    if trigger.args[2] == '*':
540
        return
541
542
    bot.server_capabilities = batched_caps
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable batched_caps does not seem to be defined.
Loading history...
543
544
    # If some other module requests it, we don't need to add another request.
545
    # If some other module prohibits it, we shouldn't request it.
546
    core_caps = [
547
        'echo-message',
548
        'multi-prefix',
549
        'away-notify',
550
        'cap-notify',
551
        'server-time',
552
    ]
553
    for cap in core_caps:
554
        if cap not in bot._cap_reqs:
555
            bot._cap_reqs[cap] = [_CapReq('', 'coretasks')]
556
557
    def acct_warn(bot, cap):
558
        LOGGER.info('Server does not support %s, or it conflicts with a custom '
559
                    'module. User account validation unavailable or limited.',
560
                    cap[1:])
561
        if bot.config.core.owner_account or bot.config.core.admin_accounts:
562
            LOGGER.warning(
563
                'Owner or admin accounts are configured, but %s is not '
564
                'supported by the server. This may cause unexpected behavior.',
565
                cap[1:])
566
    auth_caps = ['account-notify', 'extended-join', 'account-tag']
567
    for cap in auth_caps:
568
        if cap not in bot._cap_reqs:
569
            bot._cap_reqs[cap] = [_CapReq('', 'coretasks', acct_warn)]
570
571
    for cap, reqs in iteritems(bot._cap_reqs):
572
        # At this point, we know mandatory and prohibited don't co-exist, but
573
        # we need to call back for optionals if they're also prohibited
574
        prefix = ''
575
        for entry in reqs:
576
            if prefix == '-' and entry.prefix != '-':
577
                entry.failure(bot, entry.prefix + cap)
578
                continue
579
            if entry.prefix:
580
                prefix = entry.prefix
581
582
        # It's not required, or it's supported, so we can request it
583
        if prefix != '=' or cap in bot.server_capabilities:
584
            # REQs fail as a whole, so we send them one capability at a time
585
            bot.write(('CAP', 'REQ', entry.prefix + cap))
0 ignored issues
show
introduced by
The variable entry does not seem to be defined for all execution paths.
Loading history...
586
        # If it's required but not in server caps, we need to call all the
587
        # callbacks
588
        else:
589
            for entry in reqs:
590
                if entry.failure and entry.prefix == '=':
591
                    entry.failure(bot, entry.prefix + cap)
592
593
    # If we want to do SASL, we have to wait before we can send CAP END. So if
594
    # we are, wait on 903 (SASL successful) to send it.
595
    if bot.config.core.auth_method == 'sasl' or bot.config.core.server_auth_method == 'sasl':
596
        bot.write(('CAP', 'REQ', 'sasl'))
597
    else:
598
        bot.write(('CAP', 'END'))
599
600
601
def receive_cap_ack_sasl(bot):
602
    # Presumably we're only here if we said we actually *want* sasl, but still
603
    # check anyway.
604
    password = None
605
    mech = None
606
    if bot.config.core.auth_method == 'sasl':
607
        password = bot.config.core.auth_password
608
        mech = bot.config.core.auth_target
609
    elif bot.config.core.server_auth_method == 'sasl':
610
        password = bot.config.core.server_auth_password
611
        mech = bot.config.core.server_auth_sasl_mech
612
    if not password:
613
        return
614
    mech = mech or 'PLAIN'
615
    bot.write(('AUTHENTICATE', mech))
616
617
618
def send_authenticate(bot, token):
619
    """Send ``AUTHENTICATE`` command to server with the given ``token``.
620
621
    :param bot: instance of IRC bot that must authenticate
622
    :param str token: authentication token
623
624
    In case the ``token`` is more than 400 bytes, we need to split it and send
625
    as many ``AUTHENTICATE`` commands as needed. If the last chunk is 400 bytes
626
    long, we must also send a last empty command (`AUTHENTICATE +` is for empty
627
    line), so the server knows we are done with ``AUTHENTICATE``.
628
629
    .. seealso::
630
631
        https://ircv3.net/specs/extensions/sasl-3.1.html#the-authenticate-command
632
633
    """
634
    # payload is a base64 encoded token
635
    payload = base64.b64encode(token.encode('utf-8'))
636
637
    # split the payload into chunks of at most 400 bytes
638
    chunk_size = 400
639
    for i in range(0, len(payload), chunk_size):
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable range does not seem to be defined.
Loading history...
640
        offset = i + chunk_size
641
        chunk = payload[i:offset]
642
        bot.write(('AUTHENTICATE', chunk))
643
644
    # send empty (+) AUTHENTICATE when payload's length is a multiple of 400
645
    if len(payload) % chunk_size == 0:
646
        bot.write(('AUTHENTICATE', '+'))
647
648
649
@sopel.module.event('AUTHENTICATE')
650
def auth_proceed(bot, trigger):
651
    if trigger.args[0] != '+':
652
        # How did we get here? I am not good with computer.
653
        return
654
    # Is this right?
655
    if bot.config.core.auth_method == 'sasl':
656
        sasl_username = bot.config.core.auth_username
657
        sasl_password = bot.config.core.auth_password
658
    elif bot.config.core.server_auth_method == 'sasl':
659
        sasl_username = bot.config.core.server_auth_username
660
        sasl_password = bot.config.core.server_auth_password
661
    else:
662
        return
663
    sasl_username = sasl_username or bot.nick
664
    sasl_token = '\0'.join((sasl_username, sasl_username, sasl_password))
665
    send_authenticate(bot, sasl_token)
666
667
668
@sopel.module.event(events.RPL_SASLSUCCESS)
669
def sasl_success(bot, trigger):
670
    bot.write(('CAP', 'END'))
671
672
673
# Live blocklist editing
674
675
676
@sopel.module.commands('blocks')
677
@sopel.module.priority('low')
678
@sopel.module.thread(False)
679
@sopel.module.unblockable
680
@sopel.module.require_admin
681
def blocks(bot, trigger):
682
    """
683
    Manage Sopel's blocking features.\
684
    See [ignore system documentation]({% link _usage/ignoring-people.md %}).
685
686
    """
687
    STRINGS = {
688
        "success_del": "Successfully deleted block: %s",
689
        "success_add": "Successfully added block: %s",
690
        "no_nick": "No matching nick block found for: %s",
691
        "no_host": "No matching hostmask block found for: %s",
692
        "invalid": "Invalid format for %s a block. Try: .blocks add (nick|hostmask) sopel",
693
        "invalid_display": "Invalid input for displaying blocks.",
694
        "nonelisted": "No %s listed in the blocklist.",
695
        'huh': "I could not figure out what you wanted to do.",
696
    }
697
698
    masks = set(s for s in bot.config.core.host_blocks if s != '')
699
    nicks = set(Identifier(nick)
700
                for nick in bot.config.core.nick_blocks
701
                if nick != '')
702
    text = trigger.group().split()
703
704
    if len(text) == 3 and text[1] == "list":
705
        if text[2] == "hostmask":
706
            if len(masks) > 0:
707
                blocked = ', '.join(unicode(mask) for mask in masks)
0 ignored issues
show
introduced by
The variable unicode does not seem to be defined in case sys.version_info.major >= 3 on line 29 is False. Are you sure this can never be the case?
Loading history...
708
                bot.say("Blocked hostmasks: {}".format(blocked))
709
            else:
710
                bot.reply(STRINGS['nonelisted'] % ('hostmasks'))
711
        elif text[2] == "nick":
712
            if len(nicks) > 0:
713
                blocked = ', '.join(unicode(nick) for nick in nicks)
714
                bot.say("Blocked nicks: {}".format(blocked))
715
            else:
716
                bot.reply(STRINGS['nonelisted'] % ('nicks'))
717
        else:
718
            bot.reply(STRINGS['invalid_display'])
719
720
    elif len(text) == 4 and text[1] == "add":
721
        if text[2] == "nick":
722
            nicks.add(text[3])
723
            bot.config.core.nick_blocks = nicks
724
            bot.config.save()
725
        elif text[2] == "hostmask":
726
            masks.add(text[3].lower())
727
            bot.config.core.host_blocks = list(masks)
728
        else:
729
            bot.reply(STRINGS['invalid'] % ("adding"))
730
            return
731
732
        bot.reply(STRINGS['success_add'] % (text[3]))
733
734
    elif len(text) == 4 and text[1] == "del":
735
        if text[2] == "nick":
736
            if Identifier(text[3]) not in nicks:
737
                bot.reply(STRINGS['no_nick'] % (text[3]))
738
                return
739
            nicks.remove(Identifier(text[3]))
740
            bot.config.core.nick_blocks = [unicode(n) for n in nicks]
741
            bot.config.save()
742
            bot.reply(STRINGS['success_del'] % (text[3]))
743
        elif text[2] == "hostmask":
744
            mask = text[3].lower()
745
            if mask not in masks:
746
                bot.reply(STRINGS['no_host'] % (text[3]))
747
                return
748
            masks.remove(mask)
749
            bot.config.core.host_blocks = [unicode(m) for m in masks]
750
            bot.config.save()
751
            bot.reply(STRINGS['success_del'] % (text[3]))
752
        else:
753
            bot.reply(STRINGS['invalid'] % ("deleting"))
754
            return
755
    else:
756
        bot.reply(STRINGS['huh'])
757
758
759
@sopel.module.event('ACCOUNT')
760
def account_notify(bot, trigger):
761
    if trigger.nick not in bot.users:
762
        bot.users[trigger.nick] = User(trigger.nick, trigger.user, trigger.host)
763
    account = trigger.args[0]
764
    if account == '*':
765
        account = None
766
    bot.users[trigger.nick].account = account
767
768
769
@sopel.module.event(events.RPL_WHOSPCRPL)
770
@sopel.module.priority('high')
771
@sopel.module.unblockable
772
def recv_whox(bot, trigger):
773
    if len(trigger.args) < 2 or trigger.args[1] not in who_reqs:
774
        # Ignored, some module probably called WHO
775
        return
776
    if len(trigger.args) != 8:
777
        return LOGGER.warning('While populating `bot.accounts` a WHO response was malformed.')
778
    _, _, channel, user, host, nick, status, account = trigger.args
779
    away = 'G' in status
780
    modes = ''.join([c for c in status if c in '~&@%+'])
781
    _record_who(bot, channel, user, host, nick, account, away, modes)
782
783
784
def _record_who(bot, channel, user, host, nick, account=None, away=None, modes=None):
785
    nick = Identifier(nick)
786
    channel = Identifier(channel)
787
    if nick not in bot.users:
788
        usr = User(nick, user, host)
789
        bot.users[nick] = usr
790
    else:
791
        usr = bot.users[nick]
792
        # check for & fill in sparse User added by handle_names()
793
        if usr.host is None and host:
794
            usr.host = host
795
        if usr.user is None and user:
796
            usr.user = user
797
    if account == '0':
798
        usr.account = None
799
    else:
800
        usr.account = account
801
    usr.away = away
802
    priv = 0
803
    if modes:
804
        mapping = {
805
            "+": sopel.module.VOICE,
806
            "%": sopel.module.HALFOP,
807
            "@": sopel.module.OP,
808
            "&": sopel.module.ADMIN,
809
            "~": sopel.module.OWNER,
810
        }
811
        for c in modes:
812
            priv = priv | mapping[c]
813
    if channel not in bot.channels:
814
        bot.channels[channel] = Channel(channel)
815
    bot.channels[channel].add_user(usr, privs=priv)
816
    if channel not in bot.privileges:
817
        bot.privileges[channel] = dict()
818
    bot.privileges[channel][nick] = priv
819
820
821
@sopel.module.event(events.RPL_WHOREPLY)
822
@sopel.module.priority('high')
823
@sopel.module.unblockable
824
def recv_who(bot, trigger):
825
    channel, user, host, _, nick, status = trigger.args[1:7]
826
    away = 'G' in status
827
    modes = ''.join([c for c in status if c in '~&@%+'])
828
    _record_who(bot, channel, user, host, nick, away=away, modes=modes)
829
830
831
@sopel.module.event(events.RPL_ENDOFWHO)
832
@sopel.module.priority('high')
833
@sopel.module.unblockable
834
def end_who(bot, trigger):
835
    if _whox_enabled(bot):
836
        who_reqs.pop(trigger.args[1], None)
837
838
839
@sopel.module.event('AWAY')
840
@sopel.module.priority('high')
841
@sopel.module.thread(False)
842
@sopel.module.unblockable
843
def track_notify(bot, trigger):
844
    if trigger.nick not in bot.users:
845
        bot.users[trigger.nick] = User(trigger.nick, trigger.user, trigger.host)
846
    user = bot.users[trigger.nick]
847
    user.away = bool(trigger.args)
848
849
850
@sopel.module.event('TOPIC')
851
@sopel.module.event(events.RPL_TOPIC)
852
@sopel.module.priority('high')
853
@sopel.module.thread(False)
854
@sopel.module.unblockable
855
def track_topic(bot, trigger):
856
    if trigger.event != 'TOPIC':
857
        channel = trigger.args[1]
858
    else:
859
        channel = trigger.args[0]
860
    if channel not in bot.channels:
861
        return
862
    bot.channels[channel].topic = trigger.args[-1]
863
864
865
@sopel.module.rule(r'(?u).*(.+://\S+).*')
866
@sopel.module.unblockable
867
def handle_url_callbacks(bot, trigger):
868
    """Dispatch callbacks on URLs
869
870
    For each URL found in the trigger, trigger the URL callback registered by
871
    the ``@url`` decorator.
872
    """
873
    schemes = bot.config.core.auto_url_schemes
874
    # find URLs in the trigger
875
    for url in sopel.tools.web.search_urls(trigger, schemes=schemes):
876
        # find callbacks for said URL
877
        for function, match in bot.search_url_callbacks(url):
878
            # trigger callback defined by the `@url` decorator
879
            if hasattr(function, 'url_regex'):
880
                function(bot, trigger, match=match)
881