1 /++
2     The Admin plugin features bot commands which help with debugging the current
3     state, like printing the current list of users, the
4     current channels, the raw incoming strings from the server, and some other
5     things along the same line.
6 
7     It also offers some less debug-y, more administrative functions, like adding
8     and removing homes on-the-fly, whitelisting or de-whitelisting account
9     names, adding/removing from the operator/staff lists, joining or leaving channels, and such.
10 
11     See_Also:
12         https://github.com/zorael/kameloso/wiki/Current-plugins#admin,
13         [kameloso.plugins.common.core],
14         [kameloso.plugins.common.misc]
15 
16     Copyright: [JR](https://github.com/zorael)
17     License: [Boost Software License 1.0](https://www.boost.org/users/license.html)
18 
19     Authors:
20         [JR](https://github.com/zorael)
21  +/
22 module kameloso.plugins.admin.base;
23 
24 version(WithAdminPlugin):
25 
26 private:
27 
28 import kameloso.plugins.admin.classifiers;
29 debug import kameloso.plugins.admin.debugging;
30 
31 import kameloso.plugins;
32 import kameloso.plugins.common.core;
33 import kameloso.plugins.common.awareness;
34 import kameloso.common : logger;
35 import kameloso.messaging;
36 import dialect.defs;
37 import std.concurrency : send;
38 import std.typecons : Flag, No, Yes;
39 import core.time : Duration;
40 
41 
42 version(OmniscientAdmin)
43 {
44     /++
45         The [kameloso.plugins.common.core.ChannelPolicy|ChannelPolicy] to mix in
46         awareness with depending on whether version `OmniscientAdmin` is set or not.
47      +/
48     enum omniscientChannelPolicy = ChannelPolicy.any;
49 }
50 else
51 {
52     /// Ditto
53     enum omniscientChannelPolicy = ChannelPolicy.home;
54 }
55 
56 
57 // AdminSettings
58 /++
59     All Admin plugin settings, gathered in a struct.
60  +/
61 @Settings struct AdminSettings
62 {
63 private:
64     import lu.uda : Unserialisable;
65 
66 public:
67     /// Toggles whether or not the plugin should react to events at all.
68     @Enabler bool enabled = true;
69 
70     @Unserialisable
71     {
72         /++
73             Toggles whether [onAnyEvent] prints the raw strings of all incoming
74             events or not.
75          +/
76         bool printRaw;
77 
78         /++
79             Toggles whether [onAnyEvent] prints the raw bytes of the *contents*
80             of events or not.
81          +/
82         bool printBytes;
83     }
84 }
85 
86 
87 // onAnyEvent
88 /++
89     Prints incoming events to the local terminal, in forms depending on
90     which flags have been set with bot commands.
91 
92     If [AdminPlugin.printRaw] is set by way of invoking [onCommandPrintRaw],
93     prints all incoming server strings.
94 
95     If [AdminPlugin.printBytes] is set by way of invoking [onCommandPrintBytes],
96     prints all incoming server strings byte by byte.
97  +/
98 debug
99 @(IRCEventHandler()
100     .onEvent(IRCEvent.Type.ANY)
101     .channelPolicy(ChannelPolicy.any)
102     .chainable(true)
103 )
104 void onAnyEvent(AdminPlugin plugin, const ref IRCEvent event)
105 {
106     if (plugin.state.settings.headless) return;
107     onAnyEventImpl(plugin, event);
108 }
109 
110 
111 // onCommandShowUser
112 /++
113     Prints the details of one or more specific, supplied users to the local terminal.
114 
115     It basically prints the matching [dialect.defs.IRCUser|IRCUsers].
116  +/
117 debug
118 version(IncludeHeavyStuff)
119 @(IRCEventHandler()
120     .onEvent(IRCEvent.Type.CHAN)
121     .onEvent(IRCEvent.Type.QUERY)
122     .permissionsRequired(Permissions.admin)
123     .channelPolicy(ChannelPolicy.home)
124     .addCommand(
125         IRCEventHandler.Command()
126             .word("user")
127             .policy(PrefixPolicy.nickname)
128             .description("[debug] Prints out information about one or more " ~
129                 "specific users to the local terminal.")
130             .addSyntax("$command [nickname] [nickname] ...")
131     )
132 )
133 void onCommandShowUser(AdminPlugin plugin, const ref IRCEvent event)
134 {
135     if (plugin.state.settings.headless) return;
136     onCommandShowUserImpl(plugin, event);
137 }
138 
139 
140 // onCommandWhoami
141 /++
142     Sends what we know of the inquiring user.
143  +/
144 @(IRCEventHandler()
145     .onEvent(IRCEvent.Type.CHAN)
146     .onEvent(IRCEvent.Type.QUERY)
147     .permissionsRequired(Permissions.anyone)
148     .channelPolicy(ChannelPolicy.home)
149     .addCommand(
150         IRCEventHandler.Command()
151             .word("whoami")
152             .policy(PrefixPolicy.prefixed)
153             .description("Replies with what we know of the inquiring user.")
154     )
155 )
156 void onCommandWhoami(AdminPlugin plugin, const ref IRCEvent event)
157 {
158     import lu.conv : Enum;
159     import std.format : format;
160 
161     immutable account = event.sender.account.length ? event.sender.account : "*";
162     string message;  // mutable
163 
164     if (event.channel.length)
165     {
166         enum pattern = "You are <h>%s<h>@<b>%s<b> (%s), class:<b>%s<b> in the scope of <b>%s<b>.";
167         message = pattern.format(
168             event.sender.nickname,
169             account,
170             event.sender.hostmask,
171             Enum!(IRCUser.Class).toString(event.sender.class_),
172             event.channel);
173     }
174     else
175     {
176         enum pattern = "You are <h>%s<h>@<b>%s<b> (%s), class:<b>%s<b> in a global scope.";
177         message = pattern.format(
178             event.sender.nickname,
179             account,
180             event.sender.hostmask,
181             Enum!(IRCUser.Class).toString(event.sender.class_));
182     }
183 
184     privmsg(plugin.state, event.channel, event.sender.nickname, message);
185 }
186 
187 
188 // onCommandSave
189 /++
190     Saves current configuration to disk.
191 
192     This saves all plugins' settings, not just this plugin's, effectively
193     regenerating the configuration file.
194  +/
195 @(IRCEventHandler()
196     .onEvent(IRCEvent.Type.CHAN)
197     .onEvent(IRCEvent.Type.QUERY)
198     .permissionsRequired(Permissions.admin)
199     .channelPolicy(ChannelPolicy.home)
200     .addCommand(
201         IRCEventHandler.Command()
202             .word("save")
203             .policy(PrefixPolicy.nickname)
204             .description("Saves current configuration.")
205     )
206 )
207 void onCommandSave(AdminPlugin plugin, const ref IRCEvent event)
208 {
209     import kameloso.thread : ThreadMessage;
210 
211     enum message = "Saving configuration to disk.";
212     privmsg(plugin.state, event.channel, event.sender.nickname, message);
213     plugin.state.mainThread.send(ThreadMessage.save());
214 }
215 
216 
217 // onCommandShowUsers
218 /++
219     Prints out the current `users` array of the [AdminPlugin]'s
220     [kameloso.plugins.common.core.IRCPluginState|IRCPluginState] to the local terminal.
221  +/
222 debug
223 version(IncludeHeavyStuff)
224 @(IRCEventHandler()
225     .onEvent(IRCEvent.Type.CHAN)
226     .onEvent(IRCEvent.Type.QUERY)
227     .permissionsRequired(Permissions.admin)
228     .channelPolicy(ChannelPolicy.home)
229     .addCommand(
230         IRCEventHandler.Command()
231             .word("users")
232             .policy(PrefixPolicy.nickname)
233             .description("[debug] Prints out the current users array to the local terminal.")
234     )
235 )
236 void onCommandShowUsers(AdminPlugin plugin)
237 {
238     if (plugin.state.settings.headless) return;
239     onCommandShowUsersImpl(plugin);
240 }
241 
242 
243 // onCommandSudo
244 /++
245     Sends supplied text to the server, verbatim.
246 
247     You need basic knowledge of IRC server strings to use this.
248  +/
249 debug
250 @(IRCEventHandler()
251     .onEvent(IRCEvent.Type.CHAN)
252     .onEvent(IRCEvent.Type.QUERY)
253     .permissionsRequired(Permissions.admin)
254     .channelPolicy(omniscientChannelPolicy)
255     .addCommand(
256         IRCEventHandler.Command()
257             .word("sudo")
258             .policy(PrefixPolicy.nickname)
259             .description("[debug] Sends supplied text to the server, verbatim.")
260             .addSyntax("$command [raw string]")
261     )
262 )
263 void onCommandSudo(AdminPlugin plugin, const ref IRCEvent event)
264 {
265     onCommandSudoImpl(plugin, event);
266 }
267 
268 
269 // onCommandQuit
270 /++
271     Sends a [dialect.defs.IRCEvent.Type.QUIT|IRCEvent.Type.QUIT] event to the server.
272 
273     If any extra text is following the "quit" command, it uses that as the quit
274     reason. Otherwise it falls back to what is specified in the configuration file.
275  +/
276 
277 @(IRCEventHandler()
278     .onEvent(IRCEvent.Type.CHAN)
279     .onEvent(IRCEvent.Type.QUERY)
280     .permissionsRequired(Permissions.admin)
281     .channelPolicy(ChannelPolicy.home)
282     .addCommand(
283         IRCEventHandler.Command()
284             .word("quit")
285             .policy(PrefixPolicy.nickname)
286             .description("Disconnects from the server and exits the program.")
287             .addSyntax("$command [optional quit reason]")
288     )
289 )
290 void onCommandQuit(AdminPlugin plugin, const ref IRCEvent event)
291 {
292     quit(plugin.state, event.content);
293 }
294 
295 
296 // onCommandHome
297 /++
298     Adds or removes channels to/from the list of currently active home channels,
299     in the [kameloso.pods.IRCBot.homeChannels|IRCBot.homeChannels] array of
300     the current [AdminPlugin]'s [kameloso.plugins.common.core.IRCPluginState|IRCPluginState].
301 
302     Merely passes on execution to [addHome] and [delHome].
303  +/
304 @(IRCEventHandler()
305     .onEvent(IRCEvent.Type.CHAN)
306     .onEvent(IRCEvent.Type.QUERY)
307     .permissionsRequired(Permissions.admin)
308     .channelPolicy(ChannelPolicy.home)
309     .addCommand(
310         IRCEventHandler.Command()
311             .word("home")
312             .policy(PrefixPolicy.prefixed)
313             .description("Adds or removes a channel to/from the list of home channels.")
314             .addSyntax("$command add [channel]")
315             .addSyntax("$command del [channel]")
316             .addSyntax("$command list")
317     )
318 )
319 void onCommandHome(AdminPlugin plugin, const ref IRCEvent event)
320 {
321     import lu.string : nom, strippedRight;
322     import std.format : format;
323     import std.typecons : Flag, No, Yes;
324 
325     void sendUsage()
326     {
327         enum pattern = "Usage: <b>%s%s<b> [add|del|list] [channel]";
328         immutable message = pattern.format(plugin.state.settings.prefix, event.aux[$-1]);
329         privmsg(plugin.state, event.channel, event.sender.nickname, message);
330     }
331 
332     if (!event.content.length)
333     {
334         return sendUsage();
335     }
336 
337     string slice = event.content.strippedRight;  // mutable
338     immutable verb = slice.nom!(Yes.inherit)(' ');
339 
340     switch (verb)
341     {
342     case "add":
343         return addHome(plugin, event, slice);
344 
345     case "del":
346         return delHome(plugin, event, slice);
347 
348     case "list":
349         enum pattern = "Current home channels: %-(<b>%s<b>, %)<b>";
350         immutable message = pattern.format(plugin.state.bot.homeChannels);
351         return privmsg(plugin.state, event.channel, event.sender.nickname, message);
352 
353     default:
354         return sendUsage();
355     }
356 }
357 
358 
359 // addHome
360 /++
361     Adds a channel to the list of currently active home channels, in the
362     [kameloso.pods.IRCBot.homeChannels|IRCBot.homeChannels] array of the
363     current [AdminPlugin]'s [kameloso.plugins.common.core.IRCPluginState|IRCPluginState].
364 
365     Follows up with a [core.thread.fiber.Fiber|Fiber] to verify that the channel
366     was actually joined.
367 
368     Params:
369         plugin = The current [AdminPlugin].
370         event = The triggering [dialect.defs.IRCEvent|IRCEvent].
371         rawChannel = The channel to be added, potentially in unstripped, cased form.
372  +/
373 void addHome(AdminPlugin plugin, const /*ref*/ IRCEvent event, const string rawChannel)
374 in (rawChannel.length, "Tried to add a home but the channel string was empty")
375 {
376     import kameloso.plugins.common.delayawait : await, unawait;
377     import kameloso.constants : BufferSize;
378     import dialect.common : isValidChannel;
379     import lu.string : stripped;
380     import std.algorithm.searching : canFind, countUntil;
381     import std.uni : toLower;
382 
383     immutable channelName = rawChannel.stripped.toLower;
384 
385     if (!channelName.isValidChannel(plugin.state.server))
386     {
387         enum message = "Invalid channel name.";
388         return privmsg(plugin.state, event.channel, event.sender.nickname, message);
389     }
390 
391     if (plugin.state.bot.homeChannels.canFind(channelName))
392     {
393         enum message = "We are already in that home channel.";
394         return privmsg(plugin.state, event.channel, event.sender.nickname, message);
395     }
396 
397     // We need to add it to the homeChannels array so as to get ChannelPolicy.home
398     // ChannelAwareness to pick up the SELFJOIN.
399     plugin.state.bot.homeChannels ~= channelName;
400     plugin.state.updates |= typeof(plugin.state.updates).bot;
401 
402     enum addedMessage = "Home added.";
403     privmsg(plugin.state, event.channel, event.sender.nickname, addedMessage);
404 
405     immutable existingChannelIndex = plugin.state.bot.guestChannels.countUntil(channelName);
406 
407     if (existingChannelIndex != -1)
408     {
409         import std.algorithm.mutation : SwapStrategy, remove;
410 
411         logger.info("We're already in this channel as a guest. Cycling.");
412 
413         // Make sure there are no duplicates between homes and channels.
414         plugin.state.bot.guestChannels = plugin.state.bot.guestChannels
415             .remove!(SwapStrategy.unstable)(existingChannelIndex);
416 
417         return cycle(plugin, channelName);
418     }
419 
420     join(plugin.state, channelName);
421 
422     // We have to follow up and see if we actually managed to join the channel
423     // There are plenty ways for it to fail.
424 
425     import kameloso.thread : CarryingFiber;
426     import core.thread : Fiber;
427 
428     static immutable IRCEvent.Type[13] joinTypes =
429     [
430         IRCEvent.Type.ERR_BANNEDFROMCHAN,
431         IRCEvent.Type.ERR_INVITEONLYCHAN,
432         IRCEvent.Type.ERR_BADCHANNAME,
433         IRCEvent.Type.ERR_LINKCHANNEL,
434         IRCEvent.Type.ERR_TOOMANYCHANNELS,
435         IRCEvent.Type.ERR_FORBIDDENCHANNEL,
436         IRCEvent.Type.ERR_CHANNELISFULL,
437         IRCEvent.Type.ERR_BADCHANNELKEY,
438         IRCEvent.Type.ERR_BADCHANNAME,
439         IRCEvent.Type.RPL_BADCHANPASS,
440         IRCEvent.Type.ERR_SECUREONLYCHAN,
441         IRCEvent.Type.ERR_SSLONLYCHAN,
442         IRCEvent.Type.SELFJOIN,
443     ];
444 
445     void joinHomeDg()
446     {
447         CarryingFiber!IRCEvent thisFiber;
448 
449         while (true)
450         {
451             thisFiber = cast(CarryingFiber!IRCEvent)(Fiber.getThis);
452             assert(thisFiber, "Incorrectly cast Fiber: `" ~ typeof(thisFiber).stringof ~ '`');
453             assert((thisFiber.payload != IRCEvent.init), "Uninitialised payload in carrying fiber");
454 
455             if (thisFiber.payload.channel == channelName) break;
456 
457             // Different channel; yield fiber, wait for another event
458             Fiber.yield();
459         }
460 
461         const followupEvent = thisFiber.payload;
462 
463         scope(exit) unawait(plugin, joinTypes[]);
464 
465         with (IRCEvent.Type)
466         switch (followupEvent.type)
467         {
468         case SELFJOIN:
469             // Success!
470             // return so as to not drop down and undo the addition below.
471             return;
472 
473         case ERR_LINKCHANNEL:
474             // We were redirected. Still assume we wanted to add this one?
475             logger.info("Redirected!");
476             plugin.state.bot.homeChannels ~= followupEvent.content.toLower;  // note: content
477             // Drop down and undo original addition
478             break;
479 
480         default:
481             enum message = "Failed to join home channel.";
482             privmsg(plugin.state, event.channel, event.sender.nickname, message);
483             break;
484         }
485 
486         // Undo original addition
487         import std.algorithm.mutation : SwapStrategy, remove;
488         import std.algorithm.searching : countUntil;
489 
490         immutable homeIndex = plugin.state.bot.homeChannels.countUntil(followupEvent.channel);
491 
492         if (homeIndex != -1)
493         {
494             plugin.state.bot.homeChannels = plugin.state.bot.homeChannels
495                 .remove!(SwapStrategy.unstable)(homeIndex);
496             plugin.state.updates |= typeof(plugin.state.updates).bot;
497         }
498         /*else
499         {
500             logger.error("Tried to remove non-existent home channel.");
501         }*/
502     }
503 
504     Fiber fiber = new CarryingFiber!IRCEvent(&joinHomeDg, BufferSize.fiberStack);
505     await(plugin, fiber, joinTypes);
506 }
507 
508 
509 // delHome
510 /++
511     Removes a channel from the list of currently active home channels, from the
512     [kameloso.pods.IRCBot.homeChannels|IRCBot.homeChannels] array of the
513     current [AdminPlugin]'s [kameloso.plugins.common.core.IRCPluginState|IRCPluginState].
514  +/
515 void delHome(AdminPlugin plugin, const ref IRCEvent event, const string rawChannel)
516 in (rawChannel.length, "Tried to delete a home but the channel string was empty")
517 {
518     import lu.string : stripped;
519     import std.algorithm.mutation : SwapStrategy, remove;
520     import std.algorithm.searching : countUntil;
521     import std.uni : toLower;
522 
523     immutable channelName = rawChannel.stripped.toLower;
524     immutable homeIndex = plugin.state.bot.homeChannels.countUntil(channelName);
525 
526     if (homeIndex == -1)
527     {
528         import std.format : format;
529 
530         enum pattern = "Channel <b>%s<b> was not listed as a home.";
531         immutable message = pattern.format(channelName);
532         return privmsg(plugin.state, event.channel, event.sender.nickname, message);
533     }
534 
535     plugin.state.bot.homeChannels = plugin.state.bot.homeChannels
536         .remove!(SwapStrategy.unstable)(homeIndex);
537     plugin.state.updates |= typeof(plugin.state.updates).bot;
538     part(plugin.state, channelName);
539 
540     if (channelName != event.channel)
541     {
542         // We didn't just leave the channel, so we can report success
543         // Otherwise we get ERR_CANNOTSENDTOCHAN
544         enum message = "Home removed.";
545         privmsg(plugin.state, event.channel, event.sender.nickname, message);
546     }
547 }
548 
549 
550 // onCommandWhitelist
551 /++
552     Adds a nickname to the list of users who may trigger the bot, to the current
553     [dialect.defs.IRCClient.Class.whitelist|IRCClient.Class.whitelist] of the
554     current [AdminPlugin]'s [kameloso.plugins.common.core.IRCPluginState|IRCPluginState].
555 
556     This is on a [kameloso.plugins.common.core.Permissions.operator|Permissions.operator] level.
557  +/
558 @(IRCEventHandler()
559     .onEvent(IRCEvent.Type.CHAN)
560     .permissionsRequired(Permissions.operator)
561     .channelPolicy(ChannelPolicy.home)
562     .addCommand(
563         IRCEventHandler.Command()
564             .word("whitelist")
565             .policy(PrefixPolicy.prefixed)
566             .description("Adds or removes an account to/from the whitelist of users " ~
567                 "(in the current channel).")
568             .addSyntax("$command add [account or nickname]")
569             .addSyntax("$command del [account or nickname]")
570             .addSyntax("$command list")
571     )
572 )
573 void onCommandWhitelist(AdminPlugin plugin, const ref IRCEvent event)
574 {
575     manageClassLists(plugin, event, IRCUser.Class.whitelist);
576 }
577 
578 
579 // onCommandElevated
580 /++
581     Adds a nickname to the list of users who may trigger the bot, to the current
582     list of [dialect.defs.IRCClient.Class.elevated|IRCClient.Class.elevated] users of the
583     current [AdminPlugin]'s [kameloso.plugins.common.core.IRCPluginState|IRCPluginState].
584 
585     This is on a [kameloso.plugins.common.core.Permissions.operator|Permissions.operator] level.
586  +/
587 @(IRCEventHandler()
588     .onEvent(IRCEvent.Type.CHAN)
589     .permissionsRequired(Permissions.operator)
590     .channelPolicy(ChannelPolicy.home)
591     .addCommand(
592         IRCEventHandler.Command()
593             .word("elevated")
594             .policy(PrefixPolicy.prefixed)
595             .description("Adds or removes an account to/from the list of elevated users " ~
596                 "(in the current channel).")
597             .addSyntax("$command add [account or nickname]")
598             .addSyntax("$command del [account or nickname]")
599             .addSyntax("$command list")
600     )
601 )
602 void onCommandElevated(AdminPlugin plugin, const ref IRCEvent event)
603 {
604     manageClassLists(plugin, event, IRCUser.Class.elevated);
605 }
606 
607 
608 // onCommandOperator
609 /++
610     Adds a nickname or account to the list of users who may trigger lower-level
611     functions of the bot, without being a full admin.
612  +/
613 @(IRCEventHandler()
614     .onEvent(IRCEvent.Type.CHAN)
615     .permissionsRequired(Permissions.staff)
616     .channelPolicy(ChannelPolicy.home)
617     .addCommand(
618         IRCEventHandler.Command()
619             .word("operator")
620             .policy(PrefixPolicy.prefixed)
621             .description("Adds or removes an account to/from the operator list of " ~
622                 "operators/moderators (of the current channel).")
623             .addSyntax("$command add [account or nickname]")
624             .addSyntax("$command del [account or nickname]")
625             .addSyntax("$command list")
626     )
627 )
628 void onCommandOperator(AdminPlugin plugin, const ref IRCEvent event)
629 {
630     manageClassLists(plugin, event, IRCUser.Class.operator);
631 }
632 
633 
634 // onCommandStaff
635 /++
636     Adds a nickname or account to the list of users who may trigger even lower level
637     functions of the bot, without being a full admin. This roughly corresponds to
638     channel owners.
639  +/
640 @(IRCEventHandler()
641     .onEvent(IRCEvent.Type.CHAN)
642     .permissionsRequired(Permissions.admin)
643     .channelPolicy(ChannelPolicy.home)
644     .addCommand(
645         IRCEventHandler.Command()
646             .word("staff")
647             .policy(PrefixPolicy.prefixed)
648             .description("Adds or removes an account to/from the staff list (of the current channel).")
649             .addSyntax("$command add [account or nickname]")
650             .addSyntax("$command del [account or nickname]")
651             .addSyntax("$command list")
652     )
653 )
654 void onCommandStaff(AdminPlugin plugin, const ref IRCEvent event)
655 {
656     return manageClassLists(plugin, event, IRCUser.Class.staff);
657 }
658 
659 
660 // onCommandBlacklist
661 /++
662     Adds a nickname to the list of users who may not trigger the bot whatsoever,
663     except on actions annotated [kameloso.plugins.common.core.Permissions.ignore|Permissions.ignore].
664 
665     This is on a [kameloso.plugins.common.core.Permissions.operator|Permissions.operator] level.
666  +/
667 @(IRCEventHandler()
668     .onEvent(IRCEvent.Type.CHAN)
669     .permissionsRequired(Permissions.operator)
670     .channelPolicy(ChannelPolicy.home)
671     .addCommand(
672         IRCEventHandler.Command()
673             .word("blacklist")
674             .policy(PrefixPolicy.prefixed)
675             .description("Adds or removes an account to/from the blacklist of " ~
676                 "people who may explicitly not trigger the bot (in the current channel).")
677             .addSyntax("$command add [account or nickname]")
678             .addSyntax("$command del [account or nickname]")
679             .addSyntax("$command list")
680     )
681 )
682 void onCommandBlacklist(AdminPlugin plugin, const ref IRCEvent event)
683 {
684     manageClassLists(plugin, event, IRCUser.Class.blacklist);
685 }
686 
687 
688 // onCommandReload
689 /++
690     Asks plugins to reload their resources and/or configuration as they see fit.
691  +/
692 @(IRCEventHandler()
693     .onEvent(IRCEvent.Type.CHAN)
694     .onEvent(IRCEvent.Type.QUERY)
695     .permissionsRequired(Permissions.admin)
696     .channelPolicy(ChannelPolicy.home)
697     .addCommand(
698         IRCEventHandler.Command()
699             .word("reload")
700             .policy(PrefixPolicy.nickname)
701             .description("Asks plugins to reload their resources and/or configuration as they see fit.")
702             .addSyntax("$command [optional plugin name]")
703     )
704 )
705 void onCommandReload(AdminPlugin plugin, const ref IRCEvent event)
706 {
707     import kameloso.thread : ThreadMessage;
708     import std.conv : text;
709 
710     immutable message = event.content.length ?
711         text("Reloading plugin \"<b>", event.content, "<b>\".") :
712         "Reloading plugins.";
713 
714     privmsg(plugin.state, event.channel, event.sender.nickname, message);
715     plugin.state.mainThread.send(ThreadMessage.reload(event.content));
716 }
717 
718 
719 // onCommandPrintRaw
720 /++
721     Toggles a flag to print all incoming events *raw*.
722 
723     This is for debugging purposes.
724  +/
725 debug
726 @(IRCEventHandler()
727     .onEvent(IRCEvent.Type.CHAN)
728     .onEvent(IRCEvent.Type.QUERY)
729     .permissionsRequired(Permissions.admin)
730     .channelPolicy(ChannelPolicy.home)
731     .addCommand(
732         IRCEventHandler.Command()
733             .word("printraw")
734             .policy(PrefixPolicy.nickname)
735             .description("[debug] Toggles a flag to print all incoming events raw.")
736     )
737 )
738 void onCommandPrintRaw(AdminPlugin plugin, const ref IRCEvent event)
739 {
740     onCommandPrintRawImpl(plugin, event);
741 }
742 
743 
744 // onCommandPrintBytes
745 /++
746     Toggles a flag to print all incoming events *as individual bytes*.
747 
748     This is for debugging purposes.
749  +/
750 debug
751 @(IRCEventHandler()
752     .onEvent(IRCEvent.Type.CHAN)
753     .onEvent(IRCEvent.Type.QUERY)
754     .permissionsRequired(Permissions.admin)
755     .channelPolicy(ChannelPolicy.home)
756     .addCommand(
757         IRCEventHandler.Command()
758             .word("printbytes")
759             .policy(PrefixPolicy.nickname)
760             .description("[debug] Toggles a flag to print all incoming events as individual bytes.")
761     )
762 )
763 void onCommandPrintBytes(AdminPlugin plugin, const ref IRCEvent event)
764 {
765     onCommandPrintBytesImpl(plugin, event);
766 }
767 
768 
769 // onCommandJoin
770 /++
771     Joins a supplied channel.
772  +/
773 @(IRCEventHandler()
774     .onEvent(IRCEvent.Type.CHAN)
775     .onEvent(IRCEvent.Type.QUERY)
776     .permissionsRequired(Permissions.admin)
777     .channelPolicy(ChannelPolicy.home)
778     .addCommand(
779         IRCEventHandler.Command()
780             .word("join")
781             .policy(PrefixPolicy.nickname)
782             .description("Joins a guest channel.")
783             .addSyntax("$command [channel]")
784     )
785 )
786 void onCommandJoin(AdminPlugin plugin, const ref IRCEvent event)
787 {
788     import lu.string : splitInto, stripped;
789 
790     if (!event.content.length)
791     {
792         enum message = "No channels to join supplied...";
793         return privmsg(plugin.state, event.channel, event.sender.nickname, message);
794     }
795 
796     string slice = event.content.stripped;  // mutable
797     string channel;
798     string key;
799 
800     cast(void)slice.splitInto(channel, key);
801     join(plugin.state, channel, key);
802 }
803 
804 
805 // onCommandPart
806 /++
807     Parts a supplied channel.
808  +/
809 @(IRCEventHandler()
810     .onEvent(IRCEvent.Type.CHAN)
811     .onEvent(IRCEvent.Type.QUERY)
812     .permissionsRequired(Permissions.admin)
813     .channelPolicy(ChannelPolicy.home)
814     .addCommand(
815         IRCEventHandler.Command()
816             .word("part")
817             .policy(PrefixPolicy.nickname)
818             .description("Parts a channel.")
819             .addSyntax("$command [channel]")
820     )
821 )
822 void onCommandPart(AdminPlugin plugin, const ref IRCEvent event)
823 {
824     import lu.string : splitInto, stripped;
825 
826     if (!event.content.length)
827     {
828         enum message = "No channels to part supplied...";
829         return privmsg(plugin.state, event.channel, event.sender.nickname, message);
830     }
831 
832     string slice = event.content.stripped;  // mutable
833     string channel;
834     string reason;
835 
836     cast(void)slice.splitInto(channel, reason);
837     part(plugin.state, channel, reason);
838 }
839 
840 
841 // onCommandSet
842 /++
843     Sets a plugin option by variable string name.
844  +/
845 @(IRCEventHandler()
846     .onEvent(IRCEvent.Type.CHAN)
847     .onEvent(IRCEvent.Type.QUERY)
848     .permissionsRequired(Permissions.admin)
849     .channelPolicy(ChannelPolicy.home)
850     .addCommand(
851         IRCEventHandler.Command()
852             .word("set")
853             .policy(PrefixPolicy.nickname)
854             .description("Changes a setting of a plugin.")
855             .addSyntax("$command [plugin].[setting]=[value]")
856     )
857 )
858 void onCommandSet(AdminPlugin plugin, const /*ref*/ IRCEvent event)
859 {
860     import kameloso.thread : CarryingFiber;
861     import kameloso.constants : BufferSize;
862     import std.typecons : Tuple;
863     import core.thread : Fiber;
864 
865     alias Payload = Tuple!(bool);
866 
867     void setSettingDg()
868     {
869         auto thisFiber = cast(CarryingFiber!Payload)Fiber.getThis;
870         assert(thisFiber, "Incorrectly cast Fiber: " ~ typeof(thisFiber).stringof);
871 
872         immutable message = thisFiber.payload[0] ?
873             "Setting changed." :
874             "Invalid syntax or plugin/setting name.";
875         privmsg(plugin.state, event.channel, event.sender.nickname, message);
876     }
877 
878     plugin.state.specialRequests ~= specialRequest!Payload(event.content, &setSettingDg);
879 }
880 
881 
882 // onCommandGet
883 /++
884     Fetches a setting of a given plugin, or a list of all settings of a given plugin
885     if no setting name supplied.
886 
887     Filename paths to certificate files and private keys will be visible to users
888     of this, so be careful with what permissions should be required.
889  +/
890 @(IRCEventHandler()
891     .onEvent(IRCEvent.Type.CHAN)
892     .onEvent(IRCEvent.Type.QUERY)
893     .permissionsRequired(Permissions.admin)
894     .channelPolicy(ChannelPolicy.home)
895     .addCommand(
896         IRCEventHandler.Command()
897             .word("get")
898             .policy(PrefixPolicy.nickname)
899             .description("Fetches a setting of a given plugin, " ~
900                 "or a list of all available settings of a given plugin.")
901             .addSyntax("$command [plugin].[setting]")
902             .addSyntax("$command [plugin]")
903     )
904 )
905 void onCommandGet(AdminPlugin plugin, const /*ref*/ IRCEvent event)
906 {
907     import kameloso.constants : BufferSize;
908     import kameloso.thread : CarryingFiber;
909     import std.typecons : Tuple;
910     import core.thread : Fiber;
911 
912     alias Payload = Tuple!(string, string, string);
913 
914     void getSettingDg()
915     {
916         auto thisFiber = cast(CarryingFiber!Payload)Fiber.getThis;
917         assert(thisFiber, "Incorrectly cast Fiber: " ~ typeof(thisFiber).stringof);
918 
919         immutable pluginName = thisFiber.payload[0];
920         immutable setting = thisFiber.payload[1];
921         immutable value = thisFiber.payload[2];
922 
923         if (!pluginName.length)
924         {
925             enum message = "Invalid plugin.";
926             return privmsg(plugin.state, event.channel, event.sender.nickname, message);
927         }
928         else if (setting.length)
929         {
930             import lu.string : contains;
931             import std.format : format;
932 
933             immutable pattern = value.contains(' ') ?
934                 "%s.%s=\"%s\"" :
935                 "%s.%s=%s";
936             immutable message = pattern.format(pluginName, setting, value);
937             privmsg(plugin.state, event.channel, event.sender.nickname, message);
938         }
939         else if (value.length)
940         {
941             privmsg(plugin.state, event.channel, event.sender.nickname, value);
942         }
943         else
944         {
945             enum message = "Invalid setting.";
946             privmsg(plugin.state, event.channel, event.sender.nickname, message);
947         }
948     }
949 
950     plugin.state.specialRequests ~= specialRequest!Payload(event.content, &getSettingDg);
951 }
952 
953 
954 // onCommandAuth
955 /++
956     Asks the [kameloso.plugins.services.connect.ConnectService|ConnectService] to
957     (re-)authenticate to services.
958  +/
959 version(WithConnectService)
960 @(IRCEventHandler()
961     .onEvent(IRCEvent.Type.CHAN)
962     .onEvent(IRCEvent.Type.QUERY)
963     .permissionsRequired(Permissions.admin)
964     .channelPolicy(ChannelPolicy.home)
965     .addCommand(
966         IRCEventHandler.Command()
967             .word("auth")
968             .policy(PrefixPolicy.nickname)
969             .description("(Re-)authenticates with services. Useful if the server " ~
970                 "has forcefully logged the bot out.")
971     )
972 )
973 void onCommandAuth(AdminPlugin plugin)
974 {
975     import kameloso.thread : ThreadMessage, boxed;
976     import std.concurrency : send;
977 
978     version(TwitchSupport)
979     {
980         if (plugin.state.server.daemon == IRCServer.Daemon.twitch) return;
981     }
982 
983     plugin.state.mainThread.send(ThreadMessage.busMessage("connect", boxed("auth")));
984 }
985 
986 
987 // onCommandStatus
988 /++
989     Dumps information about the current state of the bot to the local terminal.
990 
991     This can be very spammy.
992  +/
993 debug
994 version(IncludeHeavyStuff)
995 @(IRCEventHandler()
996     .onEvent(IRCEvent.Type.CHAN)
997     .onEvent(IRCEvent.Type.QUERY)
998     .permissionsRequired(Permissions.admin)
999     .channelPolicy(ChannelPolicy.home)
1000     .addCommand(
1001         IRCEventHandler.Command()
1002             .word("status")
1003             .policy(PrefixPolicy.nickname)
1004             .description("[debug] Dumps information about the current state of the bot to the local terminal.")
1005     )
1006 )
1007 void onCommandStatus(AdminPlugin plugin)
1008 {
1009     if (plugin.state.settings.headless) return;
1010     onCommandStatusImpl(plugin);
1011 }
1012 
1013 
1014 // onCommandSummary
1015 /++
1016     Causes a connection summary to be printed to the terminal.
1017  +/
1018 @(IRCEventHandler()
1019     .onEvent(IRCEvent.Type.CHAN)
1020     .onEvent(IRCEvent.Type.QUERY)
1021     .permissionsRequired(Permissions.admin)
1022     .channelPolicy(ChannelPolicy.home)
1023     .addCommand(
1024         IRCEventHandler.Command()
1025             .word("summary")
1026             .policy(PrefixPolicy.nickname)
1027             .description("Prints a connection summary to the local terminal.")
1028     )
1029 )
1030 void onCommandSummary(AdminPlugin plugin)
1031 {
1032     import kameloso.thread : ThreadMessage;
1033 
1034     if (plugin.state.settings.headless) return;
1035     plugin.state.mainThread.send(ThreadMessage.wantLiveSummary());
1036 }
1037 
1038 
1039 // onCommandCycle
1040 /++
1041     Cycles (parts and immediately rejoins) a channel.
1042  +/
1043 @(IRCEventHandler()
1044     .onEvent(IRCEvent.Type.CHAN)
1045     .onEvent(IRCEvent.Type.QUERY)
1046     .permissionsRequired(Permissions.admin)
1047     .channelPolicy(ChannelPolicy.home)
1048     .addCommand(
1049         IRCEventHandler.Command()
1050             .word("cycle")
1051             .policy(PrefixPolicy.nickname)
1052             .description("Cycles (parts and rejoins) a channel.")
1053             .addSyntax("$command [optional channel] [optional delay] [optional key(s)]")
1054     )
1055 )
1056 void onCommandCycle(AdminPlugin plugin, const /*ref*/ IRCEvent event)
1057 {
1058     import kameloso.time : DurationStringException, abbreviatedDuration;
1059     import lu.string : nom, stripped;
1060     import std.conv : ConvException;
1061 
1062     string slice = event.content.stripped;  // mutable
1063 
1064     if (!slice.length)
1065     {
1066         return cycle(plugin, event.channel);
1067     }
1068 
1069     immutable channelName = slice.nom!(Yes.inherit)(' ');
1070 
1071     if (channelName !in plugin.state.channels)
1072     {
1073         enum message = "I am not in that channel.";
1074         return privmsg(plugin.state, event.channel, event.sender.nickname, message);
1075     }
1076 
1077     if (!slice.length)
1078     {
1079         return cycle(plugin, channelName);
1080     }
1081 
1082     immutable delaystring = slice.nom!(Yes.inherit)(' ');
1083 
1084     try
1085     {
1086         immutable delay = abbreviatedDuration(delaystring);
1087         cycle(plugin, channelName, delay, slice);
1088     }
1089     catch (ConvException _)
1090     {
1091         import std.format : format;
1092 
1093         enum pattern = `"<b>%s<b>" is not a valid number for seconds to delay.`;
1094         immutable message = pattern.format(slice);
1095         privmsg(plugin.state, event.channel, event.sender.nickname, message);
1096     }
1097     catch (DurationStringException e)
1098     {
1099         privmsg(plugin.state, event.channel, event.sender.nickname, e.msg);
1100     }
1101 }
1102 
1103 
1104 // cycle
1105 /++
1106     Implementation of cycling, called by [onCommandCycle]
1107 
1108     Params:
1109         plugin = The current [AdminPlugin].
1110         channelName = The name of the channel to cycle.
1111         delay_ = [core.time.Duration|Duration] to delay rejoining.
1112         key = The key to use when rejoining the channel.
1113  +/
1114 void cycle(
1115     AdminPlugin plugin,
1116     const string channelName,
1117     const Duration delay_ = Duration.zero,
1118     const string key = string.init)
1119 {
1120     import kameloso.plugins.common.delayawait : await, delay, unawait;
1121     import kameloso.constants : BufferSize;
1122     import kameloso.thread : CarryingFiber;
1123     import core.thread : Fiber;
1124 
1125     void cycleDg()
1126     {
1127         while (true)
1128         {
1129             auto thisFiber = cast(CarryingFiber!IRCEvent)(Fiber.getThis);
1130             assert(thisFiber, "Incorrectly cast Fiber: `" ~ typeof(thisFiber).stringof ~ '`');
1131             assert((thisFiber.payload != IRCEvent.init), "Uninitialised payload in carrying fiber");
1132 
1133             const partEvent = thisFiber.payload;
1134 
1135             if (partEvent.channel == channelName)
1136             {
1137                 void joinDg()
1138                 {
1139                     join(plugin.state, channelName, key);
1140                 }
1141 
1142                 unawait(plugin, Fiber.getThis, IRCEvent.Type.SELFPART);
1143 
1144                 return (delay_ == Duration.zero) ?
1145                     joinDg() :
1146                     delay(plugin, &joinDg, delay_);
1147             }
1148 
1149             // Wrong channel, wait for the next SELFPART
1150             Fiber.yield();
1151         }
1152     }
1153 
1154     Fiber fiber = new CarryingFiber!IRCEvent(&cycleDg, BufferSize.fiberStack);
1155     await(plugin, fiber, IRCEvent.Type.SELFPART);
1156     part(plugin.state, channelName, "Cycling");
1157 }
1158 
1159 
1160 // onCommandMask
1161 /++
1162     Adds, removes or lists hostmasks used to identify users on servers that
1163     don't employ services.
1164  +/
1165 @(IRCEventHandler()
1166     .onEvent(IRCEvent.Type.CHAN)
1167     .onEvent(IRCEvent.Type.QUERY)
1168     .permissionsRequired(Permissions.admin)
1169     .channelPolicy(ChannelPolicy.home)
1170     .addCommand(
1171         IRCEventHandler.Command()
1172             .word("hostmask")
1173             .policy(PrefixPolicy.prefixed)
1174             .description("Modifies a hostmask definition, for use on servers without services accounts.")
1175             .addSyntax("$command add [account] [hostmask]")
1176             .addSyntax("$command del [hostmask]")
1177             .addSyntax("$command list")
1178     )
1179     .addCommand(
1180         IRCEventHandler.Command()
1181             .word("mask")
1182             .policy(PrefixPolicy.prefixed)
1183             .hidden(true)
1184     )
1185 )
1186 void onCommandMask(AdminPlugin plugin, const ref IRCEvent event)
1187 {
1188     import lu.string : SplitResults, contains, nom, splitInto, stripped;
1189     import std.format : format;
1190 
1191     if (!plugin.state.settings.preferHostmasks)
1192     {
1193         enum message = "This bot is not currently configured to use hostmasks for authentication.";
1194         return privmsg(plugin.state, event.channel, event.sender.nickname, message);
1195     }
1196 
1197     void sendUsage()
1198     {
1199         enum pattern = "Usage: <b>%s%s<b> [add|del|list] [args...]";
1200         immutable message = pattern.format(plugin.state.settings.prefix, event.aux[$-1]);
1201         privmsg(plugin.state, event.channel, event.sender.nickname, message);
1202     }
1203 
1204     string slice = event.content.stripped;  // mutable
1205     immutable verb = slice.nom!(Yes.inherit)(' ');
1206 
1207     switch (verb)
1208     {
1209     case "add":
1210         string account;
1211         string mask;
1212 
1213         immutable results = slice.splitInto(account, mask);
1214 
1215         if (results != SplitResults.match)
1216         {
1217             enum pattern = "Usage: <b>%s%s add<b> [account] [hostmask]";
1218             immutable message = pattern.format(plugin.state.settings.prefix, event.aux[$-1]);
1219             return privmsg(plugin.state, event.channel, event.sender.nickname, message);
1220         }
1221 
1222         return modifyHostmaskDefinition(plugin, Yes.add, account, mask, event);
1223 
1224     case "del":
1225     case "remove":
1226         if (!slice.length || slice.contains(' '))
1227         {
1228             enum pattern = "Usage: <b>%s%s del<b> [hostmask]";
1229             immutable message = pattern.format(plugin.state.settings.prefix, event.aux[$-1]);
1230             return privmsg(plugin.state, event.channel, event.sender.nickname, message);
1231         }
1232 
1233         return modifyHostmaskDefinition(plugin, No.add, string.init, slice, event);
1234 
1235     case "list":
1236         return listHostmaskDefinitions(plugin, event);
1237 
1238     default:
1239         return sendUsage();
1240     }
1241 }
1242 
1243 
1244 // listHostmaskDefinitions
1245 /++
1246     Lists existing hostmask definitions.
1247 
1248     Params:
1249         plugin = The current [AdminPlugin].
1250         event = The instigating [dialect.defs.IRCEvent|IRCEvent].
1251  +/
1252 void listHostmaskDefinitions(AdminPlugin plugin, const ref IRCEvent event)
1253 {
1254     import lu.json : JSONStorage, populateFromJSON;
1255 
1256     if (plugin.state.settings.headless) return;
1257 
1258     JSONStorage json;
1259     json.load(plugin.hostmasksFile);
1260 
1261     string[string] aa;
1262     aa.populateFromJSON(json);
1263 
1264     // Remove any placeholder examples
1265     enum examplePlaceholderKey = "<nickname>!<ident>@<address>";
1266     aa.remove(examplePlaceholderKey);
1267 
1268     if (aa.length)
1269     {
1270         if (event == IRCEvent.init)
1271         {
1272             import std.json : JSONValue;
1273             import std.stdio : stdout, writeln;
1274 
1275             if (plugin.state.settings.headless) return;
1276 
1277             logger.log("Current hostmasks:");
1278             // json can contain the example placeholder, so make a new one out of aa
1279             writeln(JSONValue(aa).toPrettyString);
1280             if (plugin.state.settings.flush) stdout.flush();
1281         }
1282         else
1283         {
1284             import std.format : format;
1285 
1286             enum pattern = "Current hostmasks: <b>%s<b>";
1287             immutable message = pattern.format(aa);
1288             privmsg(plugin.state, event.channel, event.sender.nickname, message);
1289         }
1290     }
1291     else
1292     {
1293         enum message = "There are presently no hostmasks defined.";
1294 
1295         if (event == IRCEvent.init)
1296         {
1297             logger.info(message);
1298         }
1299         else
1300         {
1301             privmsg(plugin.state, event.channel, event.sender.nickname, message);
1302         }
1303     }
1304 }
1305 
1306 
1307 // onCommandReconnect
1308 /++
1309     Disconnect from and immediately reconnects to the server.
1310  +/
1311 @(IRCEventHandler()
1312     .onEvent(IRCEvent.Type.CHAN)
1313     .onEvent(IRCEvent.Type.QUERY)
1314     .permissionsRequired(Permissions.admin)
1315     .channelPolicy(ChannelPolicy.home)
1316     .addCommand(
1317         IRCEventHandler.Command()
1318             .word("reconnect")
1319             .policy(PrefixPolicy.nickname)
1320             .description("Disconnects from and immediately reconnects to the server.")
1321             .addSyntax("$command [optional quit message]")
1322     )
1323 )
1324 void onCommandReconnect(AdminPlugin plugin, const ref IRCEvent event)
1325 {
1326     import kameloso.thread : ThreadMessage, boxed;
1327     import lu.string : stripped;
1328     import std.concurrency : prioritySend;
1329 
1330     logger.warning("Reconnecting upon administrator request.");
1331     plugin.state.mainThread.send(ThreadMessage.reconnect(event.content.stripped, boxed(false)));
1332 }
1333 
1334 
1335 // onCommandReexec
1336 /++
1337     Re-executes the program.
1338  +/
1339 version(Posix)
1340 @(IRCEventHandler()
1341     .onEvent(IRCEvent.Type.CHAN)
1342     .onEvent(IRCEvent.Type.QUERY)
1343     .permissionsRequired(Permissions.admin)
1344     .channelPolicy(ChannelPolicy.home)
1345     .addCommand(
1346         IRCEventHandler.Command()
1347             .word("reexec")
1348             .policy(PrefixPolicy.nickname)
1349             .description("Re-executes the program.")
1350             .addSyntax("$command [optional quit message]")
1351     )
1352 )
1353 void onCommandReexec(AdminPlugin plugin, const ref IRCEvent event)
1354 {
1355     import kameloso.thread : ThreadMessage, boxed;
1356     import lu.string : stripped;
1357     import std.concurrency : prioritySend;
1358 
1359     plugin.state.mainThread.send(ThreadMessage.reconnect(event.content.stripped, boxed(true)));
1360 }
1361 
1362 
1363 // onCommandBus
1364 /++
1365     Sends an internal bus message to other plugins, much like how such can be
1366     sent with the Pipeline plugin.
1367  +/
1368 debug
1369 @(IRCEventHandler()
1370     .onEvent(IRCEvent.Type.CHAN)
1371     .onEvent(IRCEvent.Type.QUERY)
1372     .permissionsRequired(Permissions.admin)
1373     .channelPolicy(ChannelPolicy.home)
1374     .addCommand(
1375         IRCEventHandler.Command()
1376             .word("bus")
1377             .policy(PrefixPolicy.nickname)
1378             .description("[debug] Sends an internal bus message.")
1379             .addSyntax("$command [header] [content]")
1380     )
1381 )
1382 void onCommandBus(AdminPlugin plugin, const ref IRCEvent event)
1383 {
1384     onCommandBusImpl(plugin, event.content);
1385 }
1386 
1387 
1388 import kameloso.thread : Sendable;
1389 
1390 // onBusMessage
1391 /++
1392     Receives a passed [kameloso.thread.Boxed|Boxed] instance with the "`admin`"
1393     header, and calls functions based on the payload message.
1394 
1395     This is used in the Pipeline plugin, to allow us to trigger admin verbs via
1396     the command-line pipe.
1397 
1398     Params:
1399         plugin = The current [AdminPlugin].
1400         header = String header describing the passed content payload.
1401         content = Message content.
1402  +/
1403 void onBusMessage(
1404     AdminPlugin plugin,
1405     const string header,
1406     shared Sendable content)
1407 {
1408     import kameloso.thread : Boxed;
1409     import lu.string : contains, nom, strippedRight;
1410 
1411     // Don't return if disabled, as it blocks us from re-enabling with verb set
1412     if (header != "admin") return;
1413 
1414     auto message = cast(Boxed!string)content;
1415     assert(message, "Incorrectly cast message: " ~ typeof(message).stringof);
1416 
1417     string slice = message.payload.strippedRight;
1418     immutable verb = slice.nom!(Yes.inherit)(' ');
1419 
1420     switch (verb)
1421     {
1422     debug
1423     {
1424         version(IncludeHeavyStuff)
1425         {
1426             import kameloso.printing : printObject;
1427             import core.memory : GC;
1428 
1429             case "users":
1430                 return onCommandShowUsers(plugin);
1431 
1432             case "status":
1433                 return onCommandStatus(plugin);
1434 
1435             case "user":
1436                 if (const user = slice in plugin.state.users)
1437                 {
1438                     printObject(*user);
1439                 }
1440                 else
1441                 {
1442                     logger.error("No such user: <l>", slice);
1443                 }
1444                 break;
1445 
1446             case "state":
1447                 return printObject(plugin.state);
1448 
1449             case "gc.stats":
1450                 import kameloso.common : printGCStats;
1451                 return printGCStats();
1452 
1453             case "gc.collect":
1454                 import std.datetime.systime : Clock;
1455 
1456                 immutable statsPre = GC.stats();
1457                 immutable timestampPre = Clock.currTime;
1458                 immutable memoryUsedPre = statsPre.usedSize;
1459 
1460                 GC.collect();
1461 
1462                 immutable statsPost = GC.stats();
1463                 immutable timestampPost = Clock.currTime;
1464                 immutable memoryUsedPost = statsPost.usedSize;
1465                 immutable memoryCollected = (memoryUsedPre - memoryUsedPost);
1466                 immutable duration = (timestampPost - timestampPre);
1467 
1468                 enum pattern = "Collected <l>%,d</> bytes of garbage in <l>%s";
1469                 return logger.infof(pattern, memoryCollected, duration);
1470 
1471             case "gc.minimize":
1472                 GC.minimize();
1473                 return logger.info("Memory minimised.");
1474         }
1475 
1476         case "printraw":
1477             plugin.adminSettings.printRaw = !plugin.adminSettings.printRaw;
1478             return;
1479 
1480         case "printbytes":
1481             plugin.adminSettings.printBytes = !plugin.adminSettings.printBytes;
1482             return;
1483     }
1484 
1485     case "reexec":
1486         import kameloso.thread : ThreadMessage, boxed;
1487         import std.concurrency : prioritySend;
1488         return plugin.state.mainThread.prioritySend(ThreadMessage.reconnect(string.init, boxed(true)));
1489 
1490     case "set":
1491         import kameloso.constants : BufferSize;
1492         import kameloso.thread : CarryingFiber;
1493         import std.typecons : Tuple;
1494         import core.thread : Fiber;
1495 
1496         alias Payload = Tuple!(bool);
1497 
1498         void setSettingBusDg()
1499         {
1500             auto thisFiber = cast(CarryingFiber!Payload)Fiber.getThis;
1501             assert(thisFiber, "Incorrectly cast Fiber: " ~ typeof(thisFiber).stringof);
1502 
1503             immutable success = thisFiber.payload[0];
1504 
1505             if (success)
1506             {
1507                 logger.log("Setting changed.");
1508             }
1509             else
1510             {
1511                 logger.error("Invalid syntax or plugin/setting name.");
1512             }
1513         }
1514 
1515         plugin.state.specialRequests ~= specialRequest!Payload(slice, &setSettingBusDg);
1516         return;
1517 
1518     case "save":
1519         import kameloso.thread : ThreadMessage;
1520 
1521         logger.log("Saving configuration to disk.");
1522         return plugin.state.mainThread.send(ThreadMessage.save());
1523 
1524     case "reload":
1525         import kameloso.thread : ThreadMessage;
1526 
1527         if (slice.length)
1528         {
1529             enum pattern = `Reloading plugin "<i>%s</>".`;
1530             logger.logf(pattern, slice);
1531         }
1532         else
1533         {
1534             logger.log("Reloading plugins.");
1535         }
1536 
1537         return plugin.state.mainThread.send(ThreadMessage.reload(slice));
1538 
1539     case "whitelist":
1540     case "elevated":
1541     case "operator":
1542     case "staff":
1543     case "blacklist":
1544         import lu.conv : Enum;
1545         import lu.string : SplitResults, splitInto;
1546 
1547         string subverb;
1548         string channelName;
1549 
1550         immutable results = slice.splitInto(subverb, channelName);
1551         if (results == SplitResults.underrun)
1552         {
1553             // verb_channel_nickname
1554             enum pattern = "Invalid bus message syntax; expected <l>%s</> " ~
1555                 "[verb] [channel] [nickname if add/del], got \"<l>%s</>\"";
1556             return logger.warningf(pattern, verb, message.payload.strippedRight);
1557         }
1558 
1559         immutable class_ = Enum!(IRCUser.Class).fromString(verb);
1560 
1561         switch (subverb)
1562         {
1563         case "add":
1564         case "del":
1565             immutable user = slice;
1566 
1567             if (!user.length)
1568             {
1569                 return logger.warning("Invalid bus message syntax; no user supplied, " ~
1570                     "only channel <l>", channelName);
1571             }
1572 
1573             if (subverb == "add")
1574             {
1575                 return lookupEnlist(plugin, user, class_, channelName);
1576             }
1577             else /*if (subverb == "del")*/
1578             {
1579                 return delist(plugin, user, class_, channelName);
1580             }
1581 
1582         case "list":
1583             return listList(plugin, channelName, class_);
1584 
1585         default:
1586             enum pattern = "Invalid bus message <l>%s</> subverb <l>%s";
1587             logger.warningf(pattern, verb, subverb);
1588             break;
1589         }
1590         break;
1591 
1592     case "hostmask":
1593         import lu.string : nom;
1594 
1595         immutable subverb = slice.nom!(Yes.inherit)(' ');
1596 
1597         switch (subverb)
1598         {
1599         case "add":
1600             import lu.string : SplitResults, splitInto;
1601 
1602             string account;
1603             string mask;
1604 
1605             immutable results = slice.splitInto(account, mask);
1606             if (results != SplitResults.match)
1607             {
1608                 return logger.warning("Invalid bus message syntax; " ~
1609                     "expected hostmask add [account] [hostmask]");
1610             }
1611 
1612             IRCEvent lvalueEvent;
1613             return modifyHostmaskDefinition(plugin, Yes.add, account, mask, lvalueEvent);
1614 
1615         case "del":
1616         case "remove":
1617             if (!slice.length)
1618             {
1619                 return logger.warning("Invalid bus message syntax; " ~
1620                     "expected hostmask del [hostmask]");
1621             }
1622 
1623             IRCEvent lvalueEvent;
1624             return modifyHostmaskDefinition(plugin, No.add, string.init, slice, lvalueEvent);
1625 
1626         case "list":
1627             IRCEvent lvalueEvent;
1628             return listHostmaskDefinitions(plugin, lvalueEvent);
1629 
1630         default:
1631             enum pattern = "Invalid bus message <l>%s</> subverb <l>%s";
1632             logger.warningf(pattern, verb, subverb);
1633             break;
1634         }
1635         break;
1636 
1637     case "summary":
1638         return onCommandSummary(plugin);
1639 
1640     default:
1641         enum pattern = "[admin] Unimplemented bus message verb: <l>%s";
1642         logger.errorf(pattern, verb);
1643         break;
1644     }
1645 }
1646 
1647 
1648 mixin UserAwareness!omniscientChannelPolicy;
1649 mixin ChannelAwareness!omniscientChannelPolicy;
1650 mixin PluginRegistration!(AdminPlugin, -4.priority);
1651 
1652 version(TwitchSupport)
1653 {
1654     mixin TwitchAwareness!omniscientChannelPolicy;
1655 }
1656 
1657 public:
1658 
1659 
1660 // AdminPlugin
1661 /++
1662     The Admin plugin is a plugin aimed for administrative use and debugging.
1663 
1664     It was historically part of the [kameloso.plugins.chatbot.ChatbotPlugin|ChatbotPlugin].
1665  +/
1666 final class AdminPlugin : IRCPlugin
1667 {
1668 package:
1669     import kameloso.constants : KamelosoFilenames;
1670 
1671     /// All Admin options gathered.
1672     AdminSettings adminSettings;
1673 
1674     /// File with user definitions. Must be the same as in `persistence.d`.
1675     @Resource string userFile = KamelosoFilenames.users;
1676 
1677     /// File with hostmasks definitions. Must be the same as in `persistence.d`.
1678     @Resource string hostmasksFile = KamelosoFilenames.hostmasks;
1679 
1680     mixin IRCPluginImpl;
1681 }