1 /++
2     Implementation of Admin plugin functionality regarding user classifiers.
3     For internal use.
4 
5     The [dialect.defs.IRCEvent|IRCEvent]-annotated handlers must be in the same module
6     as the [kameloso.plugins.admin.base.AdminPlugin|AdminPlugin], but these implementation
7     functions can be offloaded here to limit module size a bit.
8 
9     See_Also:
10         [kameloso.plugins.admin.base]
11 
12     Copyright: [JR](https://github.com/zorael)
13     License: [Boost Software License 1.0](https://www.boost.org/users/license.html)
14 
15     Authors:
16         [JR](https://github.com/zorael)
17  +/
18 module kameloso.plugins.admin.classifiers;
19 
20 version(WithAdminPlugin):
21 
22 private:
23 
24 import kameloso.plugins.admin.base;
25 
26 import kameloso.plugins.common.misc : nameOf;
27 import kameloso.common : logger;
28 import kameloso.messaging;
29 import dialect.defs;
30 import std.algorithm.comparison : among;
31 import std.typecons : Flag, No, Yes;
32 
33 package:
34 
35 
36 // manageClassLists
37 /++
38     Common code for enlisting and delisting nicknames/accounts.
39 
40     Params:
41         plugin = The current [kameloso.plugins.admin.base.AdminPlugin|AdminPlugin].
42         event = The triggering [dialect.defs.IRCEvent|IRCEvent].
43         class_ = User class.
44  +/
45 void manageClassLists(
46     AdminPlugin plugin,
47     const ref IRCEvent event,
48     const IRCUser.Class class_)
49 {
50     import lu.string : beginsWith, nom, stripped;
51     import std.typecons : Flag, No, Yes;
52 
53     void sendUsage()
54     {
55         import lu.conv : Enum;
56         import std.format : format;
57 
58         enum pattern = "Usage: <b>%s%s<b> [add|del|list]";
59         immutable message = pattern.format(plugin.state.settings.prefix, Enum!(IRCUser.Class).toString(class_));
60         privmsg(plugin.state, event.channel, event.sender.nickname, message);
61     }
62 
63     if (!event.content.length)
64     {
65         return sendUsage();
66     }
67 
68     string slice = event.content.stripped;  // mutable
69     immutable verb = slice.nom!(Yes.inherit)(' ');
70     if (slice.beginsWith('@')) slice = slice[1..$];
71 
72     switch (verb)
73     {
74     case "add":
75         return lookupEnlist(plugin, slice, class_, event.channel, event);
76 
77     case "del":
78         return delist(plugin, slice, class_, event.channel, event);
79 
80     case "list":
81         return listList(plugin, event.channel, class_, event);
82 
83     default:
84         return sendUsage();
85     }
86 }
87 
88 
89 // listList
90 /++
91     Sends a list of the current users in the whitelist, operator list, list of
92     elevated users, staff, or the blacklist to the querying user or channel.
93 
94     Params:
95         plugin = The current [kameloso.plugins.admin.base.AdminPlugin|AdminPlugin].
96         channelName = The channel the list relates to.
97         class_ = User class.
98         event = Optional [dialect.defs.IRCEvent|IRCEvent] that instigated the listing.
99  +/
100 void listList(
101     AdminPlugin plugin,
102     const string channelName,
103     const IRCUser.Class class_,
104     const IRCEvent event = IRCEvent.init)
105 {
106     import lu.conv : Enum;
107     import lu.json : JSONStorage;
108     import std.format : format;
109 
110     immutable role = getNoun(NounForm.plural, class_);
111     immutable list = Enum!(IRCUser.Class).toString(class_);
112 
113     JSONStorage json;
114     json.load(plugin.userFile);
115 
116     if ((channelName in json[list].object) && json[list][channelName].array.length)
117     {
118         import std.algorithm.iteration : map;
119 
120         auto userlist = json[list][channelName].array
121             .map!(jsonEntry => jsonEntry.str);
122 
123         if (event.sender.nickname.length)
124         {
125             enum pattern = "Current %s in <b>%s<b>: %-(<h>%s<h>, %)<h>";
126             immutable message = pattern.format(role, channelName, userlist);
127             privmsg(plugin.state, event.channel, event.sender.nickname, message);
128         }
129         else
130         {
131             enum pattern = "Current %s in <l>%s</>: %-(<h>%s</>, %)</>";
132             logger.infof(pattern, role, channelName, userlist);
133         }
134     }
135     else
136     {
137         if (event.sender.nickname.length)
138         {
139             enum pattern = "There are no %s in <b>%s<b>.";
140             immutable message = pattern.format(role, channelName);
141             privmsg(plugin.state, event.channel, event.sender.nickname, message);
142         }
143         else
144         {
145             enum pattern = "There are no %s in <l>%s</>.";
146             logger.infof(pattern, role, channelName);
147         }
148     }
149 }
150 
151 
152 // lookupEnlist
153 /++
154     Adds an account to either the whitelist, operator list, list of elevated users,
155     staff, or the blacklist.
156 
157     Passes the `list` parameter to [alterAccountClassifier], for list selection.
158 
159     Params:
160         plugin = The current [kameloso.plugins.admin.base.AdminPlugin|AdminPlugin].
161         specified = The nickname or account to white-/blacklist.
162         class_ = User class.
163         channelName = Which channel the enlisting relates to.
164         event = Optional instigating [dialect.defs.IRCEvent|IRCEvent].
165  +/
166 void lookupEnlist(
167     AdminPlugin plugin,
168     const string specified,
169     const IRCUser.Class class_,
170     const string channelName,
171     const IRCEvent event = IRCEvent.init)
172 {
173     import dialect.common : isValidNickname;
174     import lu.string : beginsWith, contains;
175 
176     static immutable IRCUser.Class[5] validClasses =
177     [
178         IRCUser.Class.staff,
179         IRCUser.Class.operator,
180         IRCUser.Class.elevated,
181         IRCUser.Class.whitelist,
182         IRCUser.Class.blacklist,
183     ];
184 
185     immutable role = getNoun(NounForm.singular, class_);
186 
187     /// Report result, either to the local terminal or to the IRC channel/sender
188     void report(const AlterationResult result, const string id)
189     {
190         import std.format : format;
191 
192         if (event.sender.nickname.length)
193         {
194             // IRC report
195 
196             with (AlterationResult)
197             final switch (result)
198             {
199             case success:
200                 enum pattern = "Added <h>%s<h> as <b>%s<b> in %s.";
201                 immutable message = pattern.format(id, role, channelName);
202                 privmsg(plugin.state, event.channel, event.sender.nickname, message);
203                 break;
204 
205             case noSuchAccount:
206             case noSuchChannel:
207                 assert(0, "Invalid delist-only `AlterationResult` passed to `lookupEnlist.report`");
208 
209             case alreadyInList:
210                 enum pattern = "<h>%s<h> was already <b>%s<b> in %s.";
211                 immutable message = pattern.format(id, role, channelName);
212                 privmsg(plugin.state, event.channel, event.sender.nickname, message);
213                 break;
214             }
215         }
216         else
217         {
218             // Terminal report
219 
220             with (AlterationResult)
221             final switch (result)
222             {
223             case success:
224                 enum pattern = "Added <h>%s</> as %s in %s.";
225                 logger.infof(pattern, id, role, channelName);
226                 break;
227 
228             case noSuchAccount:
229             case noSuchChannel:
230                 assert(0, "Invalid enlist-only `AlterationResult` passed to `lookupEnlist.report`");
231 
232             case alreadyInList:
233                 enum pattern = "<h>%s</> is already %s in %s.";
234                 logger.infof(pattern, id, role, channelName);
235                 break;
236             }
237         }
238     }
239 
240     auto removeAndApply(const string name, /*const*/ string account = string.init)
241     {
242         if (!account.length) account = name;
243 
244         // Remove previous classification from all but the requested class
245         foreach (immutable thisClass; validClasses[])
246         {
247             if (thisClass == class_) continue;
248 
249             alterAccountClassifier(
250                 plugin,
251                 No.add,
252                 thisClass,
253                 account,
254                 channelName);
255         }
256 
257         // Make the class change and report
258         immutable result = alterAccountClassifier(
259             plugin,
260             Yes.add,
261             class_,
262             account,
263             channelName);
264 
265         return report(result, name);
266     }
267 
268     const user = specified in plugin.state.users;
269 
270     if (user && user.account.length)
271     {
272         // Account known, skip ahead
273         return removeAndApply(user.account, nameOf(*user));
274     }
275     else if (!specified.length)
276     {
277         if (event.sender.nickname.length)
278         {
279             // IRC report
280             enum message = "No nickname supplied.";
281             privmsg(plugin.state, event.channel, event.sender.nickname, message);
282         }
283         else
284         {
285             // Terminal report
286             logger.warning("No nickname supplied.");
287         }
288         return;
289     }
290     else if (!specified.isValidNickname(plugin.state.server))
291     {
292         if (event.sender.nickname.length)
293         {
294             import std.format : format;
295 
296             // IRC report
297 
298             enum pattern = "Invalid nickname/account: <4>%s<c>";
299             immutable message = pattern.format(specified);
300             privmsg(plugin.state, event.channel, event.sender.nickname, message);
301         }
302         else
303         {
304             // Terminal report
305             enum pattern = "Invalid nickname/account: <l>%s";
306             logger.warningf(pattern, specified);
307         }
308         return;
309     }
310 
311     void onSuccess(const string id)
312     {
313         version(TwitchSupport)
314         {
315             if (plugin.state.server.daemon == IRCServer.Daemon.twitch)
316             {
317                 import std.algorithm.iteration : filter;
318 
319                 if (const userInList = id in plugin.state.users)
320                 {
321                     return removeAndApply(nameOf(*userInList), id);
322                 }
323 
324                 // If we're here, assume a display name was specified and look up the account
325                 auto usersWithThisDisplayName = plugin.state.users
326                     .byValue
327                     .filter!(u => u.displayName == id);
328 
329                 if (!usersWithThisDisplayName.empty)
330                 {
331                     return removeAndApply(id, usersWithThisDisplayName.front.account);
332                 }
333 
334                 // Assume a valid account was specified even if we can't see it, and drop down
335             }
336         }
337 
338         return removeAndApply(id);
339     }
340 
341     void onFailure(const IRCUser failureUser)
342     {
343         logger.trace("(Assuming unauthenticated nickname or offline account was specified)");
344         return onSuccess(failureUser.nickname);
345     }
346 
347     version(TwitchSupport)
348     {
349         if (plugin.state.server.daemon == IRCServer.Daemon.twitch)
350         {
351             // Can't WHOIS on Twitch
352             return onSuccess(specified);
353         }
354     }
355 
356     // User not on record or on record but no account; WHOIS and try based on results
357     import kameloso.plugins.common.mixins : WHOISFiberDelegate;
358 
359     mixin WHOISFiberDelegate!(onSuccess, onFailure);
360 
361     enqueueAndWHOIS(specified);
362 }
363 
364 
365 // NounForm
366 /++
367     Forms in which [getNoun] should produce conjugated nouns.
368  +/
369 enum NounForm
370 {
371     /++
372         Indefinite form.
373      +/
374     indefinite,
375 
376     /++
377         Singular form (definite).
378      +/
379     singular,
380 
381     /++
382         Plural form.
383      +/
384     plural,
385 }
386 
387 
388 // getNoun
389 /++
390     Returns the string of a [dialect.defs.IRCUser.Class|Class] noun conjugated
391     to the passed form.
392 
393     Params:
394         form = Form to conjugate the noun to.
395         class_ = [dialect.defs.IRCUser.Class|IRCUser.Class] whose string to conjugate.
396 
397     Returns:
398         The string name of `class_` conjugated to `form`.
399  +/
400 auto getNoun(const NounForm form, const IRCUser.Class class_)
401 {
402     with (NounForm)
403     with (IRCUser.Class)
404     final switch (form)
405     {
406     case indefinite:
407         final switch (class_)
408         {
409         case admin:      return "administrator";
410         case staff:      return "staff";
411         case operator:   return "operator";
412         case elevated:   return "elevated user";
413         case whitelist:  return "whitelisted user";
414         case registered: return "registered user";
415         case anyone:     return "anyone";
416         case blacklist:  return "blacklisted user";
417         case unset:      return "unset";
418         }
419 
420     case singular:
421         final switch (class_)
422         {
423         case admin:      return "an administrator";
424         case staff:      return "staff";
425         case operator:   return "an operator";
426         case elevated:   return "an elevated user";
427         case whitelist:  return "a whitelisted user";
428         case registered: return "a registered user";
429         case anyone:     return "anyone";
430         case blacklist:  return "a blacklisted user";
431         case unset:      return "unset";
432         }
433 
434     case plural:
435         final switch (class_)
436         {
437         case admin:      return "administrators";
438         case staff:      return "staff";
439         case operator:   return "operators";
440         case elevated:   return "elevated users";
441         case registered: return "registered users";
442         case whitelist:  return "whitelisted users";
443         case anyone:     return "anyone";
444         case blacklist:  return "blacklisted users";
445         case unset:      return "unset";
446         }
447     }
448 }
449 
450 
451 // getNoun
452 /++
453     Returns the string of a noun conjugated to the passed form.
454 
455     Overload that takes a string instead of an [dialect.defs.IRCUser.Class|IRCUser.Class].
456 
457     Params:
458         form = Form to conjugate the noun to.
459         classString = Class string to conjugate.
460 
461     Returns:
462         The string name of `class_` conjugated to `form`.
463  +/
464 auto getNoun(const NounForm form, const string classString)
465 {
466     import lu.conv : Enum;
467     return getNoun(form, Enum!(IRCUser.Class).fromString(classString));
468 }
469 
470 
471 // delist
472 /++
473     Removes a nickname from either the whitelist, operator list, list of elevated
474     users, staff, or the blacklist.
475 
476     Passes the `list` parameter to [alterAccountClassifier], for list selection.
477 
478     Params:
479         plugin = The current [kameloso.plugins.admin.base.AdminPlugin|AdminPlugin].
480         account = The account to delist.
481         class_ = User class.
482         channelName = Which channel the enlisting relates to.
483         event = Optional instigating [dialect.defs.IRCEvent|IRCEvent].
484  +/
485 void delist(
486     AdminPlugin plugin,
487     const string account,
488     const IRCUser.Class class_,
489     const string channelName,
490     const IRCEvent event = IRCEvent.init)
491 {
492     import lu.conv : Enum;
493     import std.format : format;
494 
495     if (!account.length)
496     {
497         if (event.sender.nickname.length)
498         {
499             // IRC report
500             enum message = "No account specified.";
501             privmsg(plugin.state, event.channel, event.sender.nickname, message);
502         }
503         else
504         {
505             // Terminal report
506             logger.warning("No account specified.");
507         }
508         return;
509     }
510 
511     immutable role = getNoun(NounForm.singular, class_);
512 
513     immutable result = alterAccountClassifier(
514         plugin,
515         No.add,
516         class_,
517         account,
518         channelName);
519 
520     if (event.sender.nickname.length)
521     {
522         // IRC report
523 
524         with (AlterationResult)
525         final switch (result)
526         {
527         case alreadyInList:
528             assert(0, "Invalid enlist-only `AlterationResult` returned to `delist`");
529 
530         case noSuchAccount:
531         case noSuchChannel:
532             enum pattern = "<h>%s<h> isn't <b>%s<b> in %s.";
533             immutable message = pattern.format(account, role, channelName);
534             privmsg(plugin.state, event.channel, event.sender.nickname, message);
535             break;
536 
537         case success:
538             enum pattern = "Removed <h>%s<h> as <b>%s<b> in %s.";
539             immutable message = pattern.format(account, role, channelName);
540             privmsg(plugin.state, event.channel, event.sender.nickname, message);
541             break;
542         }
543     }
544     else
545     {
546         // Terminal report
547 
548         with (AlterationResult)
549         final switch (result)
550         {
551         case alreadyInList:
552             assert(0, "Invalid enlist-only `AlterationResult` returned to `delist`");
553 
554         case noSuchAccount:
555             enum pattern = "No such account <h>%s</> was found as %s in %s.";
556             logger.infof(pattern, account, role, channelName);
557             break;
558 
559         case noSuchChannel:
560             enum pattern = "Account <h>%s</> isn't %s in %s.";
561             logger.infof(pattern, account, role, channelName);
562             break;
563 
564         case success:
565             enum pattern = "Removed <h>%s</> as %s in %s.";
566             logger.infof(pattern, account, role, channelName);
567             break;
568         }
569     }
570 }
571 
572 
573 // AlterationResult
574 /++
575     Enum embodying the results of an account alteration.
576 
577     Returned by functions to report success or failure, to let them give terminal
578     or IRC feedback appropriately.
579  +/
580 enum AlterationResult
581 {
582     alreadyInList,  /// When enlisting, an account already existed.
583     noSuchAccount,  /// When delisting, an account could not be found.
584     noSuchChannel,  /// When delisting, a channel count not be found.
585     success,        /// Successful enlist/delist.
586 }
587 
588 
589 // alterAccountClassifier
590 /++
591     Adds or removes an account from the file of user classifier definitions,
592     and reloads all plugins to make them read the updated lists.
593 
594     Params:
595         plugin = The current [kameloso.plugins.admin.base.AdminPlugin|AdminPlugin].
596         add = Whether to add to or remove from lists.
597         class_ = User class.
598         account = Services account name to add or remove.
599         channelName = Channel the account-class applies to.
600 
601     Returns:
602         [AlterationResult.alreadyInList] if enlisting (`Yes.add`) and the account
603         was already in the specified list.
604         [AlterationResult.noSuchAccount] if delisting (`No.add`) and no such
605         account could be found in the specified list.
606         [AlterationResult.noSuchChannel] if delisting (`No.add`) and no such
607         channel could be found in the specified list.
608         [AlterationResult.success] if enlisting or delisting succeeded.
609  +/
610 auto alterAccountClassifier(
611     AdminPlugin plugin,
612     const Flag!"add" add,
613     const IRCUser.Class class_,
614     const string account,
615     const string channelName)
616 {
617     import kameloso.thread : ThreadMessage;
618     import lu.conv : Enum;
619     import lu.json : JSONStorage;
620     import std.concurrency : send;
621     import std.json : JSONValue;
622 
623     JSONStorage json;
624     json.load(plugin.userFile);
625 
626     immutable list = Enum!(IRCUser.Class).toString(class_);
627 
628     if (add)
629     {
630         import std.algorithm.searching : canFind;
631 
632         immutable accountAsJSON = JSONValue(account);
633 
634         if (channelName in json[list].object)
635         {
636             if (json[list][channelName].array.canFind(accountAsJSON))
637             {
638                 return AlterationResult.alreadyInList;
639             }
640             else
641             {
642                 json[list][channelName].array ~= accountAsJSON;
643             }
644         }
645         else
646         {
647             json[list][channelName] = null;
648             json[list][channelName].array = null;
649             json[list][channelName].array ~= accountAsJSON;
650         }
651 
652         // Remove placeholder example since there should now be at least one true entry
653         enum examplePlaceholderKey = "<#channel>";
654         json[list].object.remove(examplePlaceholderKey);
655     }
656     else
657     {
658         import std.algorithm.mutation : SwapStrategy, remove;
659         import std.algorithm.searching : countUntil;
660 
661         if (channelName in json[list].object)
662         {
663             immutable index = json[list][channelName].array.countUntil(JSONValue(account));
664 
665             if (index == -1)
666             {
667                 return AlterationResult.noSuchAccount;
668             }
669 
670             json[list][channelName] = json[list][channelName].array
671                 .remove!(SwapStrategy.unstable)(index);
672         }
673         else
674         {
675             return AlterationResult.noSuchChannel;
676         }
677     }
678 
679     json.save(plugin.userFile);
680 
681     version(WithPersistenceService)
682     {
683         // Force persistence to reload the file with the new changes
684         plugin.state.mainThread.send(ThreadMessage.reload("persistence"));
685     }
686 
687     return AlterationResult.success;
688 }
689 
690 
691 // modifyHostmaskDefinition
692 /++
693     Adds or removes hostmasks used to identify users on servers that don't employ services.
694 
695     Params:
696         plugin = The current [kameloso.plugins.admin.base.AdminPlugin|AdminPlugin].
697         add = Whether to add or to remove the hostmask.
698         account = Account the hostmask will equate to. May be empty if `add` is false.
699         mask = String "nickname!ident@address.tld" hostmask.
700         event = Instigating [dialect.defs.IRCEvent|IRCEvent].
701  +/
702 void modifyHostmaskDefinition(
703     AdminPlugin plugin,
704     const Flag!"add" add,
705     const string account,
706     const string mask,
707     const ref IRCEvent event)
708 in ((!add || account.length), "Tried to add a hostmask with no account to map it to")
709 in (mask.length, "Tried to add an empty hostmask definition")
710 {
711     import kameloso.pods : CoreSettings;
712     import kameloso.thread : ThreadMessage;
713     import lu.json : JSONStorage, populateFromJSON;
714     import lu.string : contains;
715     import std.concurrency : send;
716     import std.conv : text;
717     import std.format : format;
718     import std.json : JSONValue;
719 
720     version(Colours)
721     {
722         import kameloso.terminal.colours : colourByHash;
723     }
724     else
725     {
726         // No-colours passthrough noop
727         static string colourByHash(const string word, const CoreSettings _)
728         {
729             return word;
730         }
731     }
732 
733     // Values from persistence.d etc
734     enum examplePlaceholderKey = "<nickname>!<ident>@<address>";
735     enum examplePlaceholderValue = "<account>";
736 
737     JSONStorage json;
738     json.load(plugin.hostmasksFile);
739 
740     string[string] aa;
741     aa.populateFromJSON(json);
742 
743     if (add)
744     {
745         import dialect.common : isValidHostmask;
746 
747         if (!mask.isValidHostmask(plugin.state.server))
748         {
749             if (event.sender.nickname.length)
750             {
751                 import std.format : format;
752                 enum pattern = `Invalid hostmask: "<b>%s<b>"; must be in the form "<b>nickname!ident@address.tld<b>".`;
753                 immutable message = pattern.format(mask);
754                 privmsg(plugin.state, event.channel, event.sender.nickname, message);
755             }
756             else
757             {
758                 enum pattern = `Invalid hostmask "<l>%s</>"; must be in the form ` ~
759                     `"<l>nickname!ident@address</>".`;
760                 logger.warningf(pattern, mask);
761             }
762             return;  // Skip saving and updating below
763         }
764 
765         aa[mask] = account;
766 
767         // Remove any placeholder example since there should now be at least one true entry
768         aa.remove(examplePlaceholderKey);
769 
770         if (event.sender.nickname.length)
771         {
772             enum pattern = `Added hostmask "<b>%s<b>", mapped to account <h>%s<h>.`;
773             immutable message = pattern.format(mask, account);
774             privmsg(plugin.state, event.channel, event.sender.nickname, message);
775         }
776         else
777         {
778             immutable colouredAccount = colourByHash(account, plugin.state.settings);
779             enum pattern = `Added hostmask "<l>%s</>", mapped to account <h>%s</>.`;
780             logger.infof(pattern, mask, colouredAccount);
781         }
782         // Drop down to save
783     }
784     else
785     {
786         // Allow for removing an invalid mask
787 
788         if (const mappedAccount = mask in aa)
789         {
790             aa.remove(mask);
791             if (!aa.length) aa[examplePlaceholderKey] = examplePlaceholderValue;
792 
793             if (event == IRCEvent.init)
794             {
795                 enum pattern = `Removed hostmask "<l>%s</>".`;
796                 logger.infof(pattern, mask);
797             }
798             else
799             {
800                 enum pattern = `Removed hostmask "<b>%s<b>".`;
801                 immutable message = pattern.format(mask);
802                 privmsg(plugin.state, event.channel, event.sender.nickname, message);
803             }
804             // Drop down to save
805         }
806         else
807         {
808             if (event.sender.nickname.length)
809             {
810                 enum pattern = `No such hostmask "<b>%s<b>" on file.`;
811                 immutable message = format(pattern, mask);
812                 privmsg(plugin.state, event.channel, event.sender.nickname, message);
813             }
814             else
815             {
816                 enum pattern = `No such hostmask "<l>%s</>" on file.`;
817                 logger.warningf(pattern, mask);
818             }
819             return;  // Skip saving and updating below
820         }
821     }
822 
823     json.reset();
824     json = JSONValue(aa);
825     json.save!(JSONStorage.KeyOrderStrategy.passthrough)(plugin.hostmasksFile);
826 
827     version(WithPersistenceService)
828     {
829         // Force persistence to reload the file with the new changes
830         plugin.state.mainThread.send(ThreadMessage.reload("persistence"));
831     }
832 }