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 }