Passed
Branch master (c1a3a6)
by dgw
01:32
created

sopel.coretasks.handle_url_callbacks()   A

Complexity

Conditions 4

Size

Total Lines 16
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

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