1 /++ 2 The Persistence service keeps track of all encountered users, gathering as much 3 information about them as possible, then injects them into 4 [dialect.defs.IRCEvent|IRCEvent]s when information about them is incomplete. 5 6 This means that even if a service only refers to a user by nickname, things 7 like its ident and address will be available to plugins as well, assuming 8 the Persistence service had seen that previously. 9 10 It has no commands. 11 12 See_Also: 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.services.persistence; 23 24 version(WithPersistenceService): 25 26 private: 27 28 import kameloso.plugins; 29 import kameloso.plugins.common.core; 30 import kameloso.common : logger; 31 import kameloso.thread : Sendable; 32 import dialect.defs; 33 34 35 // postprocess 36 /++ 37 Hijacks a reference to a [dialect.defs.IRCEvent|IRCEvent] after parsing and 38 fleshes out the [dialect.defs.IRCEvent.sender|IRCEvent.sender] and/or 39 [dialect.defs.IRCEvent.target|IRCEvent.target] fields, so that things like 40 account names that are only sent sometimes carry over. 41 42 Merely leverages [postprocessCommon]. 43 +/ 44 void postprocess(PersistenceService service, ref IRCEvent event) 45 { 46 with (IRCEvent.Type) 47 switch (event.type) 48 { 49 case ERR_WASNOSUCHNICK: 50 case ERR_NOSUCHNICK: 51 case RPL_LOGGEDIN: 52 case ERR_NICKNAMEINUSE: 53 // Invalid user or inapplicable, don't complete it 54 return; 55 56 case NICK: 57 case SELFNICK: 58 // Clone the stored sender into a new stored target. 59 // Don't delete the old user yet. 60 61 if (const stored = event.sender.nickname in service.users) 62 { 63 service.users[event.target.nickname] = *stored; 64 65 auto newUser = event.target.nickname in service.users; 66 newUser.nickname = event.target.nickname; 67 68 if (service.state.settings.preferHostmasks) 69 { 70 // Drop all privileges 71 newUser.class_ = IRCUser.Class.anyone; 72 newUser.account = string.init; 73 newUser.updated = 1L; // must not be 0L 74 } 75 } 76 77 if (!service.state.settings.preferHostmasks) 78 { 79 if (const channelName = event.sender.nickname in service.userClassChannelCache) 80 { 81 service.userClassChannelCache[event.target.nickname] = *channelName; 82 } 83 } 84 85 goto default; 86 87 default: 88 return postprocessCommon(service, event); 89 } 90 } 91 92 93 // postprocessCommon 94 /++ 95 Postprocessing implementation common for service and hostmasks mode. 96 +/ 97 void postprocessCommon(PersistenceService service, ref IRCEvent event) 98 { 99 static void postprocessImpl(PersistenceService service, ref IRCEvent event, ref IRCUser user) 100 { 101 import std.algorithm.searching : canFind; 102 103 // Ignore server events and certain pre-registration events where our nick is unknown 104 if (!user.nickname.length || (user.nickname == "*")) return; 105 106 /++ 107 Returns the recorded "account" of a user. For use in hostmasks mode. 108 +/ 109 static string getAccount(PersistenceService service, const IRCUser user) 110 { 111 if (const cachedAccount = user.nickname in service.hostmaskNicknameAccountCache) 112 { 113 return *cachedAccount; 114 } 115 116 foreach (const storedUser; service.hostmaskUsers) 117 { 118 import dialect.common : matchesByMask; 119 120 if (!storedUser.account.length) continue; 121 122 if (matchesByMask(user, storedUser)) 123 { 124 service.hostmaskNicknameAccountCache[user.nickname] = storedUser.account; 125 return storedUser.account; 126 } 127 } 128 129 return string.init; 130 } 131 132 /++ 133 Tries to apply any permanent class for a user in a channel, and if 134 none available, tries to set one that seems to apply based on what 135 the user looks like. 136 +/ 137 static void applyClassifiers( 138 PersistenceService service, 139 const ref IRCEvent event, 140 ref IRCUser user) 141 { 142 if ((user.class_ == IRCUser.Class.admin) && (user.account != "*")) 143 { 144 // Do nothing, admin is permanent and program-wide 145 // unless it's someone logging out 146 return; 147 } 148 149 if (service.state.settings.preferHostmasks && !user.account.length) 150 { 151 user.account = getAccount(service, user); 152 if (user.account.length) user.updated = event.time; 153 } 154 155 bool set; 156 157 if (!user.account.length || (user.account == "*")) 158 { 159 // No account means it's just a random 160 user.class_ = IRCUser.Class.anyone; 161 set = true; 162 } 163 else if (service.state.bot.admins.canFind(user.account)) 164 { 165 // admin discovered 166 user.class_ = IRCUser.Class.admin; 167 return; 168 } 169 else if (event.channel.length) 170 { 171 if (const classAccounts = event.channel in service.channelUsers) 172 { 173 if (const definedClass = user.account in *classAccounts) 174 { 175 // Permanent class is defined, so apply it 176 user.class_ = *definedClass; 177 set = true; 178 } 179 } 180 } 181 182 if (!set) 183 { 184 // All else failed, consider it a random registered or anyone, depending on server 185 user.class_ = (service.state.server.daemon == IRCServer.Daemon.twitch) ? 186 IRCUser.Class.anyone : 187 IRCUser.Class.registered; 188 } 189 190 // Record this channel as being the one the current class_ applies to. 191 // That way we only have to look up a class_ when the channel has changed. 192 service.userClassChannelCache[user.nickname] = event.channel; 193 } 194 195 // Save cache lookups so we don't do them more than once. 196 string* cachedChannel; 197 198 auto stored = user.nickname in service.users; 199 immutable persistentCacheMiss = stored is null; 200 201 if (service.state.settings.preferHostmasks) 202 { 203 // Ignore any account that may have been parsed 204 user.account = string.init; 205 } 206 else /*if (!service.state.settings.preferHostmasks)*/ 207 { 208 if (service.state.server.daemon != IRCServer.Daemon.twitch) 209 { 210 // Apply class here on events that carry new account information. 211 212 with (IRCEvent.Type) 213 switch (event.type) 214 { 215 case JOIN: 216 case RPL_WHOISACCOUNT: 217 case RPL_WHOISUSER: 218 case RPL_WHOISREGNICK: 219 applyClassifiers(service, event, user); 220 break; 221 222 case ACCOUNT: 223 if (stored.account.length && (user.account == "*")) 224 { 225 event.aux[0] = stored.account; 226 goto case RPL_WHOISACCOUNT; 227 } 228 break; 229 230 default: 231 if ((user.account.length && (user.account != "*")) || 232 (!persistentCacheMiss && !stored.account.length)) 233 { 234 // Unexpected event bearing new account 235 // These can be whatever if the "account-tag" capability is set 236 goto case RPL_WHOISACCOUNT; 237 } 238 break; 239 } 240 } 241 } 242 243 if (persistentCacheMiss) 244 { 245 service.users[user.nickname] = user; 246 stored = user.nickname in service.users; 247 } 248 else 249 { 250 import lu.meld : MeldingStrategy, meldInto; 251 // Meld into the stored user, and store the union in the event 252 // Skip if the current stored is just a direct copy of user 253 // Store initial class and restore after meld. The origin user.class_ 254 // can ever only be IRCUser.Class.unset UNLESS altered in the switch above. 255 // Additionally snapshot the .updated value and restore it after melding 256 257 version(TwitchSupport) 258 { 259 if (service.state.server.daemon == IRCServer.Daemon.twitch) 260 { 261 if (!event.channel.length) 262 { 263 stored.badges = string.init; 264 } 265 else if (stored.badges.length && !user.badges.length) 266 { 267 // The current user doesn't have any badges and the stored one 268 // does, potentially for a different channel. Look it up and 269 // save the AA lookup pointer for later checks, in case we 270 // have to do this again down below. 271 272 /*const*/ cachedChannel = stored.nickname in service.userClassChannelCache; 273 274 if (!cachedChannel || (*cachedChannel != event.channel)) 275 { 276 // Current event has no badges but the stored one has 277 // and for a different channel. Clear them. 278 stored.badges = string.init; 279 } 280 } 281 } 282 } 283 284 immutable preMeldClass = stored.class_; 285 immutable preMeldUpdated = stored.updated; 286 user.meldInto!(MeldingStrategy.aggressive)(*stored); 287 stored.updated = preMeldUpdated; 288 289 if (stored.class_ == IRCUser.Class.unset) 290 { 291 // The class was not changed, restore the previously saved one 292 stored.class_ = preMeldClass; 293 } 294 } 295 296 if (service.state.server.daemon != IRCServer.Daemon.twitch) 297 { 298 if (!service.state.settings.preferHostmasks) 299 { 300 with (IRCEvent.Type) 301 switch (event.type) 302 { 303 case RPL_WHOISACCOUNT: 304 case RPL_WHOISREGNICK: 305 case RPL_ENDOFWHOIS: 306 // Record updated timestamp; this is the end of a WHOIS 307 stored.updated = event.time; 308 break; 309 310 case ACCOUNT: 311 case JOIN: 312 if (stored.account == "*") 313 { 314 // An account of "*" means the user logged out of services 315 // It's not strictly true but consider him/her as unknown again. 316 stored.account = string.init; 317 stored.class_ = IRCUser.Class.anyone; 318 stored.updated = 1L; // To facilitate melding 319 service.userClassChannelCache.remove(stored.nickname); 320 } 321 else 322 { 323 // Record updated timestamp; new account known 324 stored.updated = event.time; 325 } 326 break; 327 328 default: 329 break; 330 } 331 } 332 else /*if (service.state.settings.preferHostmasks)*/ 333 { 334 if (event.type == IRCEvent.Type.RPL_ENDOFWHOIS) 335 { 336 // As above 337 stored.updated = event.time; 338 } 339 } 340 } 341 342 version(TwitchSupport) 343 { 344 // Clear badges if it has the empty placeholder asterisk 345 if ((service.state.server.daemon == IRCServer.Daemon.twitch) && 346 (stored.badges == "*")) 347 { 348 stored.badges = string.init; 349 } 350 } 351 352 if ((stored.class_ == IRCUser.Class.admin) && (stored.account != "*")) 353 { 354 // Do nothing, admin is permanent and program-wide 355 // unless it's someone logging out 356 } 357 else if (!event.channel.length) 358 { 359 // Not in a channel. Additionally not an admin 360 // Default to registered if the user has an account, except on Twitch 361 // postprocess in twitch/base.d will assign class as per badges 362 363 if (service.state.server.daemon == IRCServer.Daemon.twitch) 364 { 365 version(TwitchSupport) 366 { 367 // This needs to be versioned becaused IRCUser.badges isn't 368 // available if not version TwitchSupport 369 stored.class_ = IRCUser.Class.anyone; 370 //stored.badges = string.init; // already done above on cache hit 371 } 372 } 373 else if (stored.account.length && (stored.account != "*")) 374 { 375 stored.class_ = IRCUser.Class.registered; 376 } 377 else 378 { 379 stored.class_ = IRCUser.Class.anyone; 380 } 381 382 service.userClassChannelCache.remove(user.nickname); 383 } 384 else /*if (channel.length)*/ 385 { 386 // Non-admin, channel present. Perform a new cache lookup if none was 387 // previously made, otherwise reuse the earlier hit. 388 389 if (!cachedChannel) 390 { 391 /*const*/ cachedChannel = stored.nickname in service.userClassChannelCache; 392 } 393 394 if (!cachedChannel || (*cachedChannel != event.channel)) 395 { 396 // User has no cached channel. Alternatively, user's cached channel 397 // is different from this one; class likely differs. 398 applyClassifiers(service, event, *stored); 399 } 400 } 401 402 // Inject the modified user into the event 403 user = *stored; 404 } 405 406 postprocessImpl(service, event, event.sender); 407 postprocessImpl(service, event, event.target); 408 } 409 410 411 // onQuit 412 /++ 413 Removes a user's [dialect.defs.IRCUser|IRCUser] entry from the `users` 414 associative array of the current [PersistenceService]'s 415 [kameloso.plugins.common.core.IRCPluginState|IRCPluginState] upon them disconnecting. 416 417 Additionally from the nickname-channel cache. 418 +/ 419 @(IRCEventHandler() 420 .onEvent(IRCEvent.Type.QUIT) 421 ) 422 void onQuit(PersistenceService service, const ref IRCEvent event) 423 { 424 if (service.state.settings.preferHostmasks) 425 { 426 service.hostmaskNicknameAccountCache.remove(event.sender.nickname); 427 } 428 429 service.users.remove(event.sender.nickname); 430 service.userClassChannelCache.remove(event.sender.nickname); 431 } 432 433 434 // onNick 435 /++ 436 Removes old user entries when someone changes nickname. The old nickname 437 no longer exists and the storage arrays should reflect that. 438 439 Annotated [kameloso.plugins.common.core.Timing.cleanup|Timing.cleanup] to 440 delay execution. 441 +/ 442 @(IRCEventHandler() 443 .onEvent(IRCEvent.Type.NICK) 444 .onEvent(IRCEvent.Type.SELFNICK) 445 .when(Timing.cleanup) 446 ) 447 void onNick(PersistenceService service, const ref IRCEvent event) 448 { 449 // onQuit already doees everything this function wants to do. 450 return onQuit(service, event); 451 } 452 453 454 // onWelcome 455 /++ 456 Reloads classifier definitions from disk. 457 458 This is normally done as part of user awareness, but we're not mixing that 459 in so we have to reinvent it. 460 +/ 461 @(IRCEventHandler() 462 .onEvent(IRCEvent.Type.RPL_WELCOME) 463 ) 464 void onWelcome(PersistenceService service) 465 { 466 import kameloso.plugins.common.delayawait : delay; 467 import kameloso.constants : BufferSize; 468 import std.typecons : Flag, No, Yes; 469 import core.thread : Fiber; 470 471 reloadAccountClassifiersFromDisk(service); 472 if (service.state.settings.preferHostmasks) reloadHostmasksFromDisk(service); 473 } 474 475 476 // onNamesReply 477 /++ 478 Catch users in a reply for the request for a NAMES list of all the 479 participants in a channel. 480 481 Freenode only sends a list of the nicknames but SpotChat sends the full 482 `user!ident@address` information. 483 484 This was copy/pasted from [kameloso.plugins.common.awareness.onUserAwarenessNamesReply] 485 to spare us the full mixin. 486 +/ 487 @(IRCEventHandler() 488 .onEvent(IRCEvent.Type.RPL_NAMREPLY) 489 ) 490 void onNamesReply(PersistenceService service, const ref IRCEvent event) 491 { 492 import kameloso.plugins.common.misc : catchUser; 493 import kameloso.irccolours : stripColours; 494 import dialect.common : IRCControlCharacter, stripModesign; 495 import lu.string : contains, nom; 496 import std.algorithm.iteration : splitter; 497 498 if (service.state.server.daemon == IRCServer.Daemon.twitch) 499 { 500 // Do nothing actually. Twitch NAMES is unreliable noise. 501 return; 502 } 503 504 auto names = event.content.splitter(' '); 505 506 foreach (immutable userstring; names) 507 { 508 if (!userstring.contains('!')) 509 { 510 // No need to check for slice.contains('@') 511 // Freenode-like, only nicknames with possible modesigns 512 // No point only registering nicknames 513 return; 514 } 515 516 // SpotChat-like, names are rich in full nick!ident@address form 517 string slice = userstring; // mutable 518 immutable signed = slice.nom('!'); 519 immutable nickname = signed.stripModesign(service.state.server); 520 //if (nickname == service.state.client.nickname) continue; 521 immutable ident = slice.nom('@'); 522 523 // Do addresses ever contain bold, italics, underlined? 524 immutable address = slice.contains(IRCControlCharacter.colour) ? 525 stripColours(slice) : 526 slice; 527 528 catchUser(service, IRCUser(nickname, ident, address)); // this melds with the default conservative strategy 529 } 530 } 531 532 533 // onWhoReply 534 /++ 535 Catch users in a reply for the request for a WHO list of all the 536 participants in a channel. 537 538 Each reply event is only for one user, unlike with NAMES. 539 +/ 540 @(IRCEventHandler() 541 .onEvent(IRCEvent.Type.RPL_WHOREPLY) 542 ) 543 void onWhoReply(PersistenceService service, const ref IRCEvent event) 544 { 545 import kameloso.plugins.common.misc : catchUser; 546 catchUser(service, event.target); 547 } 548 549 550 // reload 551 /++ 552 Reloads the service, rehashing the user array and loading 553 admin/staff/operator/elevated/whitelist/blacklist classifier definitions from disk. 554 +/ 555 void reload(PersistenceService service) 556 { 557 service.users = service.users.rehash(); 558 reloadAccountClassifiersFromDisk(service); 559 if (service.state.settings.preferHostmasks) reloadHostmasksFromDisk(service); 560 } 561 562 563 // reloadAccountClassifiersFromDisk 564 /++ 565 Reloads admin/staff/operator/elevated/whitelist/blacklist classifier definitions from disk. 566 567 Params: 568 service = The current [PersistenceService]. 569 +/ 570 void reloadAccountClassifiersFromDisk(PersistenceService service) 571 { 572 import lu.conv : Enum; 573 import lu.json : JSONStorage; 574 import std.json : JSONException; 575 576 JSONStorage json; 577 json.load(service.userFile); 578 579 service.channelUsers.clear(); 580 581 static immutable classes = 582 [ 583 IRCUser.Class.staff, 584 IRCUser.Class.operator, 585 IRCUser.Class.elevated, 586 IRCUser.Class.whitelist, 587 IRCUser.Class.blacklist, 588 ]; 589 590 foreach (class_; classes) 591 { 592 immutable list = Enum!(IRCUser.Class).toString(class_); 593 const listFromJSON = list in json; 594 595 if (!listFromJSON) 596 { 597 // Something's wrong, the file is missing sections and must have been manually modified 598 continue; 599 } 600 601 try 602 { 603 foreach (immutable channelName, const channelAccountJSON; listFromJSON.object) 604 { 605 import lu.string : beginsWith; 606 607 if (channelName.beginsWith('<')) continue; 608 609 foreach (immutable userJSON; channelAccountJSON.array) 610 { 611 auto theseUsers = channelName in service.channelUsers; 612 613 if (!theseUsers) 614 { 615 service.channelUsers[channelName] = (IRCUser.Class[string]).init; 616 theseUsers = channelName in service.channelUsers; 617 } 618 619 (*theseUsers)[userJSON.str] = class_; 620 } 621 } 622 } 623 catch (JSONException e) 624 { 625 enum pattern = "JSON exception caught when populating <l>%s</>: <l>%s"; 626 logger.warningf(pattern, list, e.msg); 627 version(PrintStacktraces) logger.trace(e.info); 628 } 629 catch (Exception e) 630 { 631 enum pattern = "Unhandled exception caught when populating <l>%s</>: <l>%s"; 632 logger.warningf(pattern, list, e.msg); 633 version(PrintStacktraces) logger.trace(e); 634 } 635 } 636 } 637 638 639 // reloadHostmasksFromDisk 640 /++ 641 Reloads hostmasks definitions from disk. 642 643 Params: 644 service = The current [PersistenceService]. 645 +/ 646 void reloadHostmasksFromDisk(PersistenceService service) 647 { 648 import lu.json : JSONStorage, populateFromJSON; 649 650 JSONStorage hostmasksJSON; 651 //hostmasksJSON.reset(); 652 hostmasksJSON.load(service.hostmasksFile); 653 654 string[string] accountByHostmask; 655 accountByHostmask.populateFromJSON(hostmasksJSON); 656 657 service.hostmaskUsers = null; 658 service.hostmaskNicknameAccountCache.clear(); 659 660 foreach (immutable hostmask, immutable account; accountByHostmask) 661 { 662 import kameloso.string : doublyBackslashed; 663 import dialect.common : isValidHostmask; 664 import lu.string : contains; 665 666 alias examplePlaceholderKey1 = PersistenceService.Placeholder.hostmask1; 667 alias examplePlaceholderKey2 = PersistenceService.Placeholder.hostmask2; 668 669 if ((hostmask == examplePlaceholderKey1) || 670 (hostmask == examplePlaceholderKey2)) 671 { 672 continue; 673 } 674 675 if (!hostmask.isValidHostmask(service.state.server)) 676 { 677 enum pattern =`Malformed hostmask in <l>%s</>: "<l>%s</>"`; 678 logger.warningf(pattern, service.hostmasksFile.doublyBackslashed, hostmask); 679 continue; 680 } 681 else if (!account.length) 682 { 683 enum pattern =`Incomplete hostmask entry in <l>%s</>: "<l>%s</>" has empty account`; 684 logger.warningf(pattern, service.hostmasksFile.doublyBackslashed, hostmask); 685 continue; 686 } 687 688 try 689 { 690 auto user = IRCUser(hostmask); 691 user.account = account; 692 service.hostmaskUsers ~= user; 693 694 if (user.nickname.length && !user.nickname.contains('*')) 695 { 696 // Nickname has length and is not a glob 697 // (adding a glob to hostmaskUsers is okay) 698 service.hostmaskNicknameAccountCache[user.nickname] = user.account; 699 } 700 } 701 catch (Exception e) 702 { 703 enum pattern =`Exception parsing hostmask in <l>%s</> ("<l>%s</>"): <l>%s`; 704 logger.warningf(pattern, service.hostmasksFile.doublyBackslashed, hostmask, e.msg); 705 version(PrintStacktraces) logger.trace(e); 706 } 707 } 708 } 709 710 711 // initResources 712 /++ 713 Initialises the service's hostmasks and accounts resources. 714 715 Merely calls [initAccountResources] and [initHostmaskResources]. 716 +/ 717 void initResources(PersistenceService service) 718 { 719 initAccountResources(service); 720 initHostmaskResources(service); 721 } 722 723 724 // initAccountResources 725 /++ 726 Reads, completes and saves the user classification JSON file, creating one 727 if one doesn't exist. Removes any duplicate entries. 728 729 This ensures there will be "staff", "operator", "elevated", "whitelist" 730 and "blacklist" arrays in it. 731 732 Params: 733 service = The current [PersistenceService]. 734 735 Throws: 736 [kameloso.plugins.common.misc.IRCPluginInitialisationException|IRCPluginInitialisationException] 737 on failure loading the `user.json` file. 738 +/ 739 void initAccountResources(PersistenceService service) 740 { 741 import lu.json : JSONStorage; 742 import std.json : JSONException, JSONValue; 743 744 JSONStorage json; 745 746 try 747 { 748 json.load(service.userFile); 749 } 750 catch (JSONException e) 751 { 752 import kameloso.plugins.common.misc : IRCPluginInitialisationException; 753 754 version(PrintStacktraces) logger.trace(e); 755 throw new IRCPluginInitialisationException( 756 "Users file is malformed", 757 service.name, 758 service.userFile, 759 __FILE__, 760 __LINE__); 761 } 762 763 // Let other Exceptions pass. 764 765 static auto deduplicate(JSONValue before) 766 { 767 import std.algorithm.iteration : filter, uniq; 768 import std.algorithm.sorting : sort; 769 import std.array : array; 770 771 auto after = before 772 .array 773 .sort!((a, b) => a.str < b.str) 774 .uniq 775 .filter!((a) => a.str.length > 0) 776 .array; 777 778 return JSONValue(after); 779 } 780 781 /+ 782 unittest 783 { 784 auto users = JSONValue([ "foo", "bar", "baz", "bar", "foo" ]); 785 assert((users.array.length == 5), users.array.length.to!string); 786 787 users = deduplicated(users); 788 assert((users == JSONValue([ "bar", "baz", "foo" ])), users.array.to!string); 789 }+/ 790 791 //import std.range : only; 792 793 static immutable listTypes = 794 [ 795 "staff", 796 "operator", 797 "elevated", 798 "whitelist", 799 "blacklist", 800 ]; 801 802 foreach (liststring; listTypes) 803 { 804 alias examplePlaceholderKey = PersistenceService.Placeholder.channel; 805 806 if (liststring !in json) 807 { 808 json[liststring] = null; 809 json[liststring].object = null; 810 json[liststring][examplePlaceholderKey] = null; 811 json[liststring][examplePlaceholderKey].array = null; 812 json[liststring][examplePlaceholderKey].array ~= JSONValue("<nickname1>"); 813 json[liststring][examplePlaceholderKey].array ~= JSONValue("<nickname2>"); 814 } 815 else 816 { 817 if ((json[liststring].object.length > 1) && 818 (examplePlaceholderKey in json[liststring].object)) 819 { 820 json[liststring].object.remove(examplePlaceholderKey); 821 } 822 823 try 824 { 825 foreach (immutable channelName, ref channelAccountsJSON; json[liststring].object) 826 { 827 if (channelName == examplePlaceholderKey) continue; 828 channelAccountsJSON = deduplicate(json[liststring][channelName]); 829 } 830 } 831 catch (JSONException e) 832 { 833 import kameloso.plugins.common.misc : IRCPluginInitialisationException; 834 import kameloso.common : logger; 835 836 version(PrintStacktraces) logger.trace(e); 837 throw new IRCPluginInitialisationException( 838 "Users file is malformed", 839 service.name, 840 service.userFile, 841 __FILE__, 842 __LINE__); 843 } 844 } 845 } 846 847 // Force staff, operator and whitelist to appear before blacklist in the .json 848 static immutable order = [ "staff", "operator", "elevated", "whitelist", "blacklist" ]; 849 json.save!(JSONStorage.KeyOrderStrategy.inGivenOrder)(service.userFile, order); 850 } 851 852 853 // initHostmaskResources 854 /++ 855 Reads, completes and saves the hostmasks JSON file, creating one if it doesn't exist. 856 857 Throws: 858 [kameloso.plugins.common.misc.IRCPluginInitialisationException|IRCPluginInitialisationException] 859 on failure loading the `hostmasks.json` file. 860 +/ 861 void initHostmaskResources(PersistenceService service) 862 { 863 import lu.json : JSONStorage; 864 import std.json : JSONException; 865 866 JSONStorage json; 867 868 try 869 { 870 json.load(service.hostmasksFile); 871 } 872 catch (JSONException e) 873 { 874 import kameloso.plugins.common.misc : IRCPluginInitialisationException; 875 import kameloso.common : logger; 876 877 version(PrintStacktraces) logger.trace(e); 878 throw new IRCPluginInitialisationException( 879 "Hostmasks file is malformed", 880 service.name, 881 service.hostmasksFile, 882 __FILE__, 883 __LINE__); 884 } 885 886 alias examplePlaceholderKey1 = PersistenceService.Placeholder.hostmask1; 887 alias examplePlaceholderKey2 = PersistenceService.Placeholder.hostmask2; 888 alias examplePlaceholderValue1 = PersistenceService.Placeholder.account1; 889 alias examplePlaceholderValue2 = PersistenceService.Placeholder.account2; 890 891 if (json.object.length == 0) 892 { 893 json[examplePlaceholderKey1] = null; 894 json[examplePlaceholderKey1].str = null; 895 json[examplePlaceholderKey1].str = examplePlaceholderValue1; 896 json[examplePlaceholderKey2] = null; 897 json[examplePlaceholderKey2].str = null; 898 json[examplePlaceholderKey2].str = examplePlaceholderValue2; 899 } 900 else if ((json.object.length > 2) && 901 ((examplePlaceholderKey1 in json) || 902 (examplePlaceholderKey2 in json))) 903 { 904 json.object.remove(examplePlaceholderKey1); 905 json.object.remove(examplePlaceholderKey2); 906 } 907 908 // Let other Exceptions pass. 909 910 // Adjust saved JSON layout to be more easily edited 911 json.save!(JSONStorage.KeyOrderStrategy.passthrough)(service.hostmasksFile); 912 } 913 914 915 mixin PluginRegistration!(PersistenceService, -50.priority); 916 917 public: 918 919 920 // PersistenceService 921 /++ 922 The Persistence service melds new [dialect.defs.IRCUser|IRCUser]s (from 923 post-processing new [dialect.defs.IRCEvent|IRCEvent]s) with old records of themselves. 924 925 Sometimes the only bit of information about a sender (or target) embedded in 926 an [dialect.defs.IRCEvent|IRCEvent] may be his/her nickname, even though the 927 event before detailed everything, even including their account name. With 928 this service we aim to complete such [dialect.defs.IRCUser|IRCUser] entries as 929 the union of everything we know from previous events. 930 931 It only needs part of [kameloso.plugins.common.awareness.UserAwareness|UserAwareness] 932 for minimal bookkeeping, not the full package, so we only copy/paste the 933 relevant bits to stay slim. 934 +/ 935 final class PersistenceService : IRCPlugin 936 { 937 private: 938 import kameloso.common : RehashingAA; 939 import kameloso.constants : KamelosoFilenames; 940 941 /++ 942 Placeholder values. 943 +/ 944 enum Placeholder 945 { 946 /// Hostmask placeholder 1. 947 hostmask1 = "<nickname1>!<ident>@<address>", 948 949 /// Hostmask placeholder 2. 950 hostmask2 = "<nickname2>!<ident>@<address>", 951 952 /// Channel placeholder. 953 channel = "<#channel>", 954 955 /// Account placeholder 1. 956 account1 = "<account1>", 957 958 /// Account placeholder 2. 959 account2 = "<account2>", 960 } 961 962 /++ 963 File with user definitions. 964 +/ 965 @Resource string userFile = KamelosoFilenames.users; 966 967 /++ 968 File with user hostmasks. 969 +/ 970 @Resource string hostmasksFile = KamelosoFilenames.hostmasks; 971 972 /++ 973 Associative array of permanent user classifications, per account and channel name. 974 +/ 975 RehashingAA!(string, IRCUser.Class)[string] channelUsers; 976 977 /++ 978 Hostmask definitions as read from file. Should be considered read-only. 979 +/ 980 IRCUser[] hostmaskUsers; 981 982 /++ 983 Cached nicknames matched to defined hostmasks. 984 +/ 985 RehashingAA!(string, string) hostmaskNicknameAccountCache; 986 987 /++ 988 Associative array of which channel the latest class lookup for an account related to. 989 +/ 990 RehashingAA!(string, string) userClassChannelCache; 991 992 /++ 993 Associative array of users. Replaces 994 [kameloso.plugins.common.core.IRCPluginState.users|IRCPluginState.users]. 995 +/ 996 RehashingAA!(string, IRCUser) users; 997 998 mixin IRCPluginImpl; 999 }