-
-
Notifications
You must be signed in to change notification settings - Fork 405
/
admin.py
495 lines (396 loc) · 15.4 KB
/
admin.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
"""
admin.py - Sopel Admin Plugin
Copyright 2010-2011, Sean B. Palmer (inamidst.com) and Michael Yanovich
(yanovich.net)
Copyright © 2012, Elad Alfassa, <elad@fedoraproject.org>
Copyright 2013, Ari Koivula <ari@koivu.la>
Copyright 2019, Florian Strzelecki, https://github.com/Exirel
Licensed under the Eiffel Forum License 2.
https://sopel.chat
"""
from __future__ import annotations
import logging
from sopel import plugin
from sopel.config import types
LOGGER = logging.getLogger(__name__)
ERROR_JOIN_NO_CHANNEL = 'Which channel should I join?'
"""Error message when channel is missing from command arguments."""
ERROR_PART_NO_CHANNEL = 'Which channel should I quit?'
"""Error message when channel is missing from command arguments."""
ERROR_NOTHING_TO_SAY = 'I need a channel and a message to talk.'
"""Error message when channel and/or message are missing."""
ERROR_NOTHING_TO_RAW = 'I need an IRC message to send.'
"""Error message when no raw IRC message was given."""
class AdminSection(types.StaticSection):
hold_ground = types.BooleanAttribute('hold_ground', default=False)
"""Auto re-join on kick"""
auto_accept_invite = types.BooleanAttribute('auto_accept_invite', default=True)
"""Auto-join channels when invited"""
def configure(config):
"""
| name | example | purpose |
| ---- | ------- | ------- |
| hold\\_ground | False | Auto-rejoin the channel after being kicked. |
| auto\\_accept\\_invite | True | Auto-join channels when invited. |
"""
config.define_section('admin', AdminSection)
config.admin.configure_setting('hold_ground',
"Automatically re-join after being kicked?")
config.admin.configure_setting('auto_accept_invite',
'Automatically join channels when invited?')
def setup(bot):
bot.config.define_section('admin', AdminSection)
class InvalidSection(Exception):
def __init__(self, section):
super().__init__(self, 'Section [{}] does not exist.'.format(section))
self.section = section
class InvalidSectionOption(Exception):
def __init__(self, section, option):
super().__init__(self, 'Section [{}] does not have option \'{}\'.'.format(section, option))
self.section = section
self.option = option
def _get_config_channels(channels):
"""List"""
for channel_info in channels:
if ' ' in channel_info:
yield channel_info.split(' ', 1)
else:
yield (channel_info, None)
def _set_config_channels(bot, channels):
bot.config.core.channels = [
' '.join([part for part in items if part])
for items in channels.items()
]
bot.config.save()
def _join(bot, channel, key=None, save=True):
if not channel:
return
if not key:
bot.join(channel)
else:
bot.join(channel, key)
if save:
channels = dict(_get_config_channels(bot.config.core.channels))
# save only if channel is new or key has been changed
if channel not in channels or channels[channel] != key:
channels[channel] = key
_set_config_channels(bot, channels)
LOGGER.info('Added "%s" to core.channels.', channel)
def _part(bot, channel, msg=None, save=True):
bot.part(channel, msg or None)
if save:
channels = dict(_get_config_channels(bot.config.core.channels))
if channel in channels:
del channels[channel]
_set_config_channels(bot, channels)
LOGGER.info('Removed "%s" from core.channels.', channel)
@plugin.require_privmsg
@plugin.require_admin
@plugin.command('join')
@plugin.priority('low')
@plugin.example('.join #example key', user_help=True)
@plugin.example('.join #example', user_help=True)
def join(bot, trigger):
"""Join the specified channel. This is an admin-only command."""
channel, key = trigger.group(3), trigger.group(4)
if not channel:
bot.reply(ERROR_JOIN_NO_CHANNEL)
return
_join(bot, channel, key)
@plugin.require_privmsg
@plugin.require_admin
@plugin.command('tmpjoin')
@plugin.priority('low')
@plugin.example('.tmpjoin #example key', user_help=True)
@plugin.example('.tmpjoin #example', user_help=True)
def temporary_join(bot, trigger):
"""Like ``join``, without saving. This is an admin-only command.
Unlike the ``join`` command, ``tmpjoin`` won't remember the channel upon
restarting the bot.
"""
channel, key = trigger.group(3), trigger.group(4)
if not channel:
bot.reply(ERROR_JOIN_NO_CHANNEL)
return
_join(bot, channel, key, save=False)
@plugin.require_privmsg
@plugin.require_admin
@plugin.command('part')
@plugin.priority('low')
@plugin.example('.part #example')
def part(bot, trigger):
"""Part the specified channel. This is an admin-only command."""
channel, _sep, part_msg = trigger.group(2).partition(' ')
if not channel:
bot.reply(ERROR_PART_NO_CHANNEL)
return
_part(bot, channel, part_msg)
@plugin.require_privmsg
@plugin.require_admin
@plugin.command('tmppart')
@plugin.priority('low')
@plugin.example('.tmppart #example')
def temporary_part(bot, trigger):
"""Like ``part``, without saving. This is an admin-only command.
Unlike the ``part`` command, ``tmppart`` will rejoin the channel upon
restarting the bot.
"""
channel, _sep, part_msg = trigger.group(2).partition(' ')
if not channel:
bot.reply(ERROR_PART_NO_CHANNEL)
return
_part(bot, channel, part_msg, save=False)
@plugin.require_privmsg
@plugin.require_admin
@plugin.command('chanlist', 'channels')
@plugin.priority('low')
def channel_list(bot, trigger):
"""Show channels Sopel is in."""
channels = ', '.join(sorted(bot.channels.keys()))
# conservative assumption about how much room we have in the line to make
# sure `max_messages` won't actually truncate anything
bot.say(channels, max_messages=1 + len(channels) // 400)
@plugin.require_privmsg
@plugin.require_owner
@plugin.command('restart')
@plugin.priority('low')
def restart(bot, trigger):
"""Restart the bot. This is an owner-only command."""
quit_message = trigger.group(2)
default_message = 'Restarting on command from %s.' % trigger.nick
if not quit_message:
quit_message = default_message
LOGGER.info(default_message)
bot.restart(quit_message)
@plugin.require_privmsg
@plugin.require_owner
@plugin.command('quit')
@plugin.priority('low')
def quit(bot, trigger):
"""Quit from the server. This is an owner-only command."""
quit_message = trigger.group(2)
default_message = 'Quitting on command from %s.' % trigger.nick
if not quit_message:
quit_message = default_message
LOGGER.info(default_message)
bot.quit(quit_message)
@plugin.require_privmsg
@plugin.require_owner
@plugin.command('raw')
@plugin.priority('low')
@plugin.example('.raw PRIVMSG NickServ :CERT ADD')
def raw(bot, trigger):
"""
Send a raw IRC message. Can only be done in privmsg by the bot owner.
This is mostly useful for debugging.
"""
if trigger.group(2) is None:
bot.reply(ERROR_NOTHING_TO_RAW)
return
bot.write([trigger.group(2)])
@plugin.require_privmsg
@plugin.require_admin
@plugin.command('say', 'msg')
@plugin.priority('low')
@plugin.example('.say #YourPants Does anyone else smell neurotoxin?')
def say(bot, trigger):
"""
Send a message to a given channel or nick. Can only be done in privmsg by
an admin.
"""
if trigger.group(2) is None:
bot.reply(ERROR_NOTHING_TO_SAY)
return
channel, _sep, message = trigger.group(2).partition(' ')
message = message.strip()
if not channel or not message:
bot.reply(ERROR_NOTHING_TO_SAY)
return
bot.say(message, channel)
@plugin.require_privmsg
@plugin.require_admin
@plugin.command('me')
@plugin.priority('low')
def me(bot, trigger):
"""
Send an ACTION (/me) to a given channel or nick. Can only be done in
privmsg by an admin.
"""
if trigger.group(2) is None:
bot.reply(ERROR_NOTHING_TO_SAY)
return
channel, _sep, action = trigger.group(2).partition(' ')
action = action.strip()
if not channel or not action:
bot.reply(ERROR_NOTHING_TO_SAY)
return
bot.action(action, channel)
@plugin.event('INVITE')
@plugin.priority('low')
def invite_join(bot, trigger):
"""Join a channel Sopel is invited to, if the inviter is an admin."""
channel = trigger.args[1]
if trigger.admin:
LOGGER.info(
'Got invited to "%s" by an admin.', channel)
bot.join(channel)
elif bot.config.admin.auto_accept_invite:
LOGGER.info(
'Got invited to "%s"; admin.auto_accept_invite is on', channel)
bot.join(channel)
else:
LOGGER.info(
'Got invited to "%s"; admin.auto_accept_invite is off.', channel)
@plugin.event('KICK')
@plugin.priority('low')
def hold_ground(bot, trigger):
"""
This function monitors all kicks across all channels Sopel is in. If it
detects that it is the one kicked it'll automatically join that channel.
WARNING: This may not be needed and could cause problems if Sopel becomes
annoying. Please use this with caution.
"""
if bot.nick != trigger.args[1]:
# not the bot; ignore
return
channel = trigger.sender
if bot.config.admin.hold_ground:
LOGGER.info('Got kicked from "%s"; admin.hold_ground is on.', channel)
bot.join(channel)
else:
LOGGER.info('Got kicked from "%s"; admin.hold_ground is off.', channel)
@plugin.require_privmsg
@plugin.require_admin
@plugin.command('mode')
@plugin.priority('low')
def mode(bot, trigger):
"""Set a user mode on Sopel. Can only be done in privmsg by an admin."""
mode = trigger.group(3)
if not mode:
bot.reply('What mode should I set?')
bot.write(('MODE', bot.nick, mode))
def parse_section_option_value(config, trigger):
"""Parse trigger for set/unset to get relevant config elements.
:param config: Sopel's config
:param trigger: IRC line trigger
:return: A tuple with ``(section, section_name, static_sec, option, value)``
:raises InvalidSection: section does not exist
:raises InvalidSectionOption: option does not exist for section
The ``value`` is optional and can be returned as ``None`` if omitted from command.
"""
match = trigger.group(3)
if match is None:
raise ValueError # Invalid command
# Get section and option from first argument.
arg1 = match.split('.')
if len(arg1) == 1:
section_name, option = "core", arg1[0]
elif len(arg1) == 2:
section_name, option = arg1
else:
raise ValueError # invalid command format
section = getattr(config, section_name, False)
if not section:
raise InvalidSection(section_name)
static_sec = isinstance(section, types.StaticSection)
if static_sec and not hasattr(section, option):
raise InvalidSectionOption(section_name, option) # Option not found in section
if not static_sec and not config.parser.has_option(section_name, option):
raise InvalidSectionOption(section_name, option) # Option not found in section
delim = trigger.group(2).find(' ')
# Skip preceding whitespaces, if any.
while delim > 0 and delim < len(trigger.group(2)) and trigger.group(2)[delim] == ' ':
delim = delim + 1
value = trigger.group(2)[delim:]
if delim == -1 or delim == len(trigger.group(2)):
value = None
return (section, section_name, static_sec, option, value)
@plugin.require_privmsg("This command only works as a private message.")
@plugin.require_admin("This command requires admin privileges.")
@plugin.command('set')
@plugin.example('.set core.owner MyNick')
def set_config(bot, trigger):
"""See and modify values of Sopel's config object.
Trigger args:
arg1 - section and option, in the form "section.option"
arg2 - value
If there is no section, section will default to "core".
If value is not provided, the current value will be displayed.
"""
try:
section, section_name, static_sec, option, value = parse_section_option_value(bot.config, trigger)
except ValueError:
bot.say('Usage: {}set section.option [value]'.format(bot.config.core.help_prefix))
return
except (InvalidSection, InvalidSectionOption) as exc:
bot.say(exc.args[1])
return
# Get a descriptor class for the option if it's a static section
descriptor = getattr(section.__class__, option) if static_sec else None
# Display current value if no value is given
if not value:
value = getattr(section, option)
if descriptor is not None:
if getattr(descriptor, 'is_secret', False):
# Keep secret option as secret
value = "(secret value censored)"
elif option.endswith("password") or option.endswith("pass"):
# Fallback to guessing if secret, for backward compatiblity
# TODO: consider a deprecation warning when loading settings
value = "(password censored)"
bot.say("%s.%s = %s (%s)" % (section_name, option, value, type(value).__name__))
return
# Owner-related settings cannot be modified interactively. Any changes to these
# settings must be made directly in the config file.
if section_name == 'core' and option in ['owner', 'owner_account']:
bot.say("Changing '{}.{}' requires manually editing the configuration file."
.format(section_name, option))
return
# Otherwise, set the value to one given
if descriptor is not None:
try:
value = descriptor._parse(value, bot.config, section)
except ValueError as exc:
bot.say("Can't set attribute: " + str(exc))
return
setattr(section, option, value)
LOGGER.info('%s.%s set successfully.', section_name, option)
bot.say("OK. Set '{}.{}' successfully.".format(section_name, option))
@plugin.require_privmsg("This command only works as a private message.")
@plugin.require_admin("This command requires admin privileges.")
@plugin.command('unset')
@plugin.example('.unset core.owner')
def unset_config(bot, trigger):
"""Unset value of Sopel's config object.
Unsetting a value will reset it to the default specified in the config
definition.
Trigger args:
arg1 - section and option, in the form "section.option"
If there is no section, section will default to "core".
"""
try:
section, section_name, static_sec, option, value = parse_section_option_value(bot.config, trigger)
except ValueError:
bot.say('Usage: {}unset section.option [value]'.format(bot.config.core.help_prefix))
return
except (InvalidSection, InvalidSectionOption) as exc:
bot.say(exc.args[1])
return
if value:
bot.say('Invalid command; no value should be provided to unset.')
return
try:
setattr(section, option, None)
LOGGER.info('%s.%s unset.', section_name, option)
bot.say("Unset '{}.{}' successfully.".format(section_name, option))
except ValueError:
bot.say('Cannot unset {}.{}; it is a required option.'.format(section_name, option))
@plugin.require_privmsg
@plugin.require_admin
@plugin.command('save')
@plugin.example('.save')
def save_config(bot, trigger):
"""Save state of Sopel's config object to the configuration file."""
bot.config.save()
LOGGER.info('Configuration file saved.')
bot.say('Configuration file saved.')