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 }