1 /++
2     The is not a plugin by itself but contains code common to all plugins,
3     without which they will *not* function.
4 
5     See_Also:
6         [kameloso.plugins.common.core]
7 
8     Copyright: [JR](https://github.com/zorael)
9     License: [Boost Software License 1.0](https://www.boost.org/users/license.html)
10 
11     Authors:
12         [JR](https://github.com/zorael)
13  +/
14 module kameloso.plugins.common.misc;
15 
16 private:
17 
18 import kameloso.plugins.common.core;
19 import kameloso.common : logger;
20 import kameloso.pods : CoreSettings;
21 import dialect.defs;
22 import std.typecons : Flag, No, Yes;
23 
24 public:
25 
26 
27 // applyCustomSettings
28 /++
29     Changes a setting of a plugin, given both the names of the plugin and the
30     setting, in string form.
31 
32     This merely iterates the passed `plugins` and calls their
33     [kameloso.plugins.common.core.IRCPlugin.setMemberByName|IRCPlugin.setMemberByName]
34     methods.
35 
36     Params:
37         plugins = Array of all [kameloso.plugins.common.core.IRCPlugin|IRCPlugin]s.
38         customSettings = Array of custom settings to apply to plugins' own
39             setting, in the string forms of "`plugin.setting=value`".
40         copyOfSettings = A copy of the program-wide [kameloso.pods.CoreSettings|CoreSettings].
41 
42     Returns:
43         `true` if no setting name mismatches occurred, `false` if it did.
44 
45     See_Also:
46         [lu.objmanip.setSettingByName]
47  +/
48 auto applyCustomSettings(
49     IRCPlugin[] plugins,
50     const string[] customSettings,
51     CoreSettings copyOfSettings)
52 {
53     import lu.objmanip : SetMemberException;
54     import lu.string : contains, nom;
55     import std.conv : ConvException;
56 
57     bool noErrors = true;
58 
59     top:
60     foreach (immutable line; customSettings)
61     {
62         if (!line.contains!(Yes.decode)('.'))
63         {
64             enum pattern = `Bad <l>plugin</>.<l>setting</>=<l>value</> format. (<l>%s</>)`;
65             logger.warningf(pattern, line);
66             noErrors = false;
67             continue;
68         }
69 
70         string slice = line;  // mutable
71         immutable pluginstring = slice.nom!(Yes.decode)(".");
72         immutable setting = slice.nom!(Yes.inherit, Yes.decode)('=');
73         immutable value = slice;
74 
75         try
76         {
77             if (pluginstring == "core")
78             {
79                 import kameloso.common : logger;
80                 import kameloso.logger : KamelosoLogger;
81                 import lu.objmanip : setMemberByName;
82                 import std.algorithm.comparison : among;
83                 static import kameloso.common;
84 
85                 immutable success = slice.length ?
86                     copyOfSettings.setMemberByName(setting, value) :
87                     copyOfSettings.setMemberByName(setting, true);
88 
89                 if (!success)
90                 {
91                     enum pattern = "No such <l>core</> setting: <l>%s";
92                     logger.warningf(pattern, setting);
93                     noErrors = false;
94                 }
95                 else
96                 {
97                     if (setting.among!("monochrome", "brightTerminal", "headless", "flush"))
98                     {
99                         logger = new KamelosoLogger(copyOfSettings);
100                     }
101 
102                     *kameloso.common.settings = copyOfSettings;
103 
104                     foreach (plugin; plugins)
105                     {
106                         plugin.state.settings = copyOfSettings;
107 
108                         // No need to flag as updated when we update here manually
109                         //plugin.state.updates |= typeof(plugin.state.updates).settings;
110                     }
111                 }
112                 continue top;
113             }
114             else
115             {
116                 foreach (plugin; plugins)
117                 {
118                     if (plugin.name != pluginstring) continue;
119 
120                     immutable success = plugin.setSettingByName(
121                         setting,
122                         value.length ? value : "true");
123 
124                     if (!success)
125                     {
126                         enum pattern = "No such <l>%s</> plugin setting: <l>%s";
127                         logger.warningf(pattern, pluginstring, setting);
128                         noErrors = false;
129                     }
130                     continue top;
131                 }
132             }
133 
134             // If we're here, the loop was never continued --> unknown plugin
135             enum pattern = "Invalid plugin: <l>%s";
136             logger.warningf(pattern, pluginstring);
137             noErrors = false;
138             // Drop down, try next
139         }
140         catch (SetMemberException e)
141         {
142             enum pattern = "Failed to set <l>%s</>.<l>%s</>: " ~
143                 "it requires a value and none was supplied.";
144             logger.warningf(pattern, pluginstring, setting);
145             version(PrintStacktraces) logger.trace(e.info);
146             noErrors = false;
147             // Drop down, try next
148         }
149         catch (ConvException e)
150         {
151             enum pattern = `Invalid value for <l>%s</>.<l>%s</>: "<l>%s</>" <t>(%s)`;
152             logger.warningf(pattern, pluginstring, setting, value, e.msg);
153             noErrors = false;
154             // Drop down, try next
155         }
156         continue top;
157     }
158 
159     return noErrors;
160 }
161 
162 ///
163 unittest
164 {
165     @Settings static struct MyPluginSettings
166     {
167         @Enabler bool enabled;
168 
169         string s;
170         int i;
171         float f;
172         bool b;
173         double d;
174     }
175 
176     static final class MyPlugin : IRCPlugin
177     {
178         MyPluginSettings myPluginSettings;
179 
180         override string name() @property const
181         {
182             return "myplugin";
183         }
184 
185         mixin IRCPluginImpl;
186     }
187 
188     IRCPluginState state;
189     IRCPlugin plugin = new MyPlugin(state);
190 
191     auto newSettings =
192     [
193         `myplugin.s="abc def ghi"`,
194         "myplugin.i=42",
195         "myplugin.f=3.14",
196         "myplugin.b=true",
197         "myplugin.d=99.99",
198     ];
199 
200     cast(void)applyCustomSettings([ plugin ], newSettings, state.settings);
201 
202     const ps = (cast(MyPlugin)plugin).myPluginSettings;
203 
204     static if (__VERSION__ >= 2091)
205     {
206         import std.math : isClose;
207     }
208     else
209     {
210         import std.math : isClose = approxEqual;
211     }
212 
213     import std.conv : to;
214 
215     assert((ps.s == "abc def ghi"), ps.s);
216     assert((ps.i == 42), ps.i.to!string);
217     assert(isClose(ps.f, 3.14f), ps.f.to!string);
218     assert(ps.b);
219     assert(isClose(ps.d, 99.99), ps.d.to!string);
220 }
221 
222 
223 // IRCPluginSettingsException
224 /++
225     Exception thrown when an IRC plugin failed to have its settings set.
226 
227     A normal [object.Exception|Exception], which only differs in the sense that
228     we can deduce what went wrong by its type.
229  +/
230 final class IRCPluginSettingsException : Exception
231 {
232     /// Wraps normal Exception constructors.
233     this(
234         const string message,
235         const string file = __FILE__,
236         const size_t line = __LINE__,
237         Throwable nextInChain = null) pure nothrow @nogc @safe
238     {
239         super(message, file, line, nextInChain);
240     }
241 }
242 
243 
244 // IRCPluginInitialisationException
245 /++
246     Exception thrown when an IRC plugin failed to initialise itself or its resources.
247 
248     A normal [object.Exception|Exception], with a plugin name and optionally the
249     name of a malformed resource file embedded.
250  +/
251 final class IRCPluginInitialisationException : Exception
252 {
253     /// Name of throwing plugin.
254     string pluginName;
255 
256     /// Optional name of a malformed file.
257     string malformedFilename;
258 
259     /++
260         Constructs an [IRCPluginInitialisationException], embedding a plugin name
261         and the name of a malformed resource file.
262      +/
263     this(
264         const string message,
265         const string pluginName,
266         const string malformedFilename,
267         const string file = __FILE__,
268         const size_t line = __LINE__,
269         Throwable nextInChain = null) pure nothrow @nogc @safe
270     {
271         this.pluginName = pluginName;
272         this.malformedFilename = malformedFilename;
273         super(message, file, line, nextInChain);
274     }
275 
276     /++
277         Constructs an [IRCPluginInitialisationException], embedding a plugin name.
278      +/
279     this(
280         const string message,
281         const string pluginName,
282         const string file = __FILE__,
283         const size_t line = __LINE__,
284         Throwable nextInChain = null) pure nothrow @nogc @safe
285     {
286         this.pluginName = pluginName;
287         super(message, file, line, nextInChain);
288     }
289 }
290 
291 
292 // catchUser
293 /++
294     Catch an [dialect.defs.IRCUser|IRCUser], saving it to the
295     [kameloso.plugins.common.core.IRCPlugin|IRCPlugin]'s
296     [kameloso.plugins.common.core.IRCPluginState.users|IRCPluginState.users] array.
297 
298     If a user already exists, meld the new information into the old one.
299 
300     Params:
301         plugin = Current [kameloso.plugins.common.core.IRCPlugin|IRCPlugin].
302         newUser = The [dialect.defs.IRCUser|IRCUser] to catch.
303  +/
304 void catchUser(IRCPlugin plugin, const IRCUser newUser) @safe
305 {
306     if (!newUser.nickname.length) return;
307 
308     if (auto user = newUser.nickname in plugin.state.users)
309     {
310         import lu.meld : meldInto;
311         newUser.meldInto(*user);
312     }
313     else
314     {
315         plugin.state.users[newUser.nickname] = newUser;
316     }
317 }
318 
319 
320 // enqueue
321 /++
322     Construct and enqueue a function replay in the plugin's queue of such.
323 
324     The main loop will catch up on it and issue WHOIS queries as necessary, then
325     replay the event upon receiving the results.
326 
327     Params:
328         plugin = Subclass [kameloso.plugins.common.core.IRCPlugin|IRCPlugin] to
329             replay the function pointer `fun` with as first argument.
330         event = [dialect.defs.IRCEvent|IRCEvent] to queue up to replay.
331         permissionsRequired = Permissions level to match the results from the WHOIS query with.
332         inFiber = Whether or not the function should be called from within a Fiber.
333         fun = Function/delegate pointer to call when the results return.
334         caller = String name of the calling function, or something else that gives context.
335  +/
336 void enqueue(Plugin, Fun)
337     (Plugin plugin,
338     const ref IRCEvent event,
339     const Permissions permissionsRequired,
340     const bool inFiber,
341     Fun fun,
342     const string caller = __FUNCTION__)
343 in ((event != IRCEvent.init), "Tried to `enqueue` with an init IRCEvent")
344 in ((fun !is null), "Tried to `enqueue` with a null function pointer")
345 {
346     import std.traits : isSomeFunction;
347 
348     static assert (isSomeFunction!Fun, "Tried to `enqueue` with a non-function function");
349 
350     version(TwitchSupport)
351     {
352         if (plugin.state.server.daemon == IRCServer.Daemon.twitch)
353         {
354             version(TwitchWarnings)
355             {
356                 import kameloso.common : logger;
357 
358                 logger.warning(caller, " tried to WHOIS on Twitch");
359 
360                 version(IncludeHeavyStuff)
361                 {
362                     import kameloso.printing : printObject;
363                     printObject(event);
364                 }
365 
366                 version(PrintStacktraces)
367                 {
368                     import kameloso.common: printStacktrace;
369                     printStacktrace();
370                 }
371             }
372             return;
373         }
374     }
375 
376     immutable user = event.sender.isServer ? event.target : event.sender;
377     assert(user.nickname.length, "Bad user derived in `enqueue` (no nickname)");
378 
379     version(ExplainReplay)
380     {
381         import lu.string : beginsWith;
382 
383         immutable callerSlice = caller.beginsWith("kameloso.plugins.") ?
384             caller[17..$] :
385             caller;
386     }
387 
388     if (const previousWhoisTimestamp = user.nickname in plugin.state.previousWhoisTimestamps)
389     {
390         import kameloso.constants : Timeout;
391         import std.datetime.systime : Clock;
392 
393         immutable now = Clock.currTime.toUnixTime;
394         immutable delta = (now - *previousWhoisTimestamp);
395 
396         if ((delta < Timeout.whoisRetry) && (delta > Timeout.whoisGracePeriod))
397         {
398             version(ExplainReplay)
399             {
400                 enum pattern = "<i>%s</> plugin <w>NOT</> queueing an event to be replayed " ~
401                     "on behalf of <i>%s</>; delta time <i>%d</> is too recent";
402                 logger.logf(pattern, plugin.name, callerSlice, delta);
403             }
404             return;
405         }
406     }
407 
408     version(ExplainReplay)
409     {
410         enum pattern = "<i>%s</> plugin queueing an event to be replayed on behalf of <i>%s";
411         logger.logf(pattern, plugin.name, callerSlice);
412     }
413 
414     plugin.state.pendingReplays[user.nickname] ~=
415         replay(plugin, event, fun, permissionsRequired, inFiber, caller);
416     plugin.state.hasPendingReplays = true;
417 }
418 
419 
420 // replay
421 /++
422     Convenience function that returns a [kameloso.plugins.common.core.Replay] of
423     the right type, *with* a subclass plugin reference attached.
424 
425     Params:
426         plugin = Subclass [kameloso.plugins.common.core.IRCPlugin|IRCPlugin] to
427             call the function pointer `fun` with as first argument, when the
428             WHOIS results return.
429         event = [dialect.defs.IRCEvent|IRCEvent] that instigated the WHOIS lookup.
430         fun = Function/delegate pointer to call upon receiving the results.
431         permissionsRequired = The permissions level policy to apply to the WHOIS results.
432         inFiber = Whether or not the function should be called from within a Fiber.
433         caller = String name of the calling function, or something else that gives context.
434 
435     Returns:
436         A [kameloso.plugins.common.core.Replay|Replay] with template parameters
437         inferred from the arguments passed to this function.
438 
439     See_Also:
440         [kameloso.plugins.common.core.Replay|Replay]
441  +/
442 auto replay(Plugin, Fun)
443     (Plugin plugin,
444     const /*ref*/ IRCEvent event,
445     Fun fun,
446     const Permissions permissionsRequired,
447     const bool inFiber,
448     const string caller = __FUNCTION__)
449 {
450     void replayDg(Replay replay)
451     {
452         import lu.conv : Enum;
453         import lu.string : beginsWith;
454 
455         version(ExplainReplay)
456         void explainReplay()
457         {
458             immutable caller = replay.caller.beginsWith("kameloso.plugins.") ?
459                 replay.caller[17..$] :
460                 replay.caller;
461 
462             enum pattern = "<i>%s</> replaying <i>%s</>-level event (invoking <i>%s</>) " ~
463                 "based on WHOIS results; user <i>%s</> is <i>%s</> class";
464             logger.logf(pattern,
465                 plugin.name,
466                 Enum!Permissions.toString(replay.permissionsRequired),
467                 caller,
468                 replay.event.sender.nickname,
469                 Enum!(IRCUser.Class).toString(replay.event.sender.class_));
470         }
471 
472         version(ExplainReplay)
473         void explainRefuse()
474         {
475             immutable caller = replay.caller.beginsWith("kameloso.plugins.") ?
476                 replay.caller[17..$] :
477                 replay.caller;
478 
479             enum pattern = "<i>%s</> plugin <w>NOT</> replaying <i>%s</>-level event " ~
480                 "(which would have invoked <i>%s</>) " ~
481                 "based on WHOIS results: user <i>%s</> is <i>%s</> class";
482             logger.logf(pattern,
483                 plugin.name,
484                 Enum!Permissions.toString(replay.permissionsRequired),
485                 caller,
486                 replay.event.sender.nickname,
487                 Enum!(IRCUser.Class).toString(replay.event.sender.class_));
488         }
489 
490         with (Permissions)
491         final switch (permissionsRequired)
492         {
493         case admin:
494             if (replay.event.sender.class_ >= IRCUser.Class.admin)
495             {
496                 goto case ignore;
497             }
498             break;
499 
500         case staff:
501             if (replay.event.sender.class_ >= IRCUser.Class.staff)
502             {
503                 goto case ignore;
504             }
505             break;
506 
507         case operator:
508             if (replay.event.sender.class_ >= IRCUser.Class.operator)
509             {
510                 goto case ignore;
511             }
512             break;
513 
514         case elevated:
515             if (replay.event.sender.class_ >= IRCUser.Class.elevated)
516             {
517                 goto case ignore;
518             }
519             break;
520 
521         case whitelist:
522             if (replay.event.sender.class_ >= IRCUser.Class.whitelist)
523             {
524                 goto case ignore;
525             }
526             break;
527 
528         case registered:
529             if (replay.event.sender.account.length)
530             {
531                 goto case ignore;
532             }
533             break;
534 
535         case anyone:
536             if (replay.event.sender.class_ >= IRCUser.Class.anyone)
537             {
538                 goto case ignore;
539             }
540 
541             // event.sender.class_ is Class.blacklist here (or unset)
542             // Do nothing and drop down
543             break;
544 
545         case ignore:
546 
547             import lu.traits : TakesParams;
548             import std.traits : arity;
549 
550             version(ExplainReplay) explainReplay();
551 
552             void call()
553             {
554                 static if (
555                     TakesParams!(fun, Plugin, IRCEvent) ||
556                     TakesParams!(fun, IRCPlugin, IRCEvent))
557                 {
558                     fun(plugin, replay.event);
559                 }
560                 else static if (
561                     TakesParams!(fun, Plugin) ||
562                     TakesParams!(fun, IRCPlugin))
563                 {
564                     fun(plugin);
565                 }
566                 else static if (
567                     TakesParams!(fun, IRCEvent))
568                 {
569                     fun(replay.event);
570                 }
571                 else static if (arity!fun == 0)
572                 {
573                     fun();
574                 }
575                 else
576                 {
577                     // onEventImpl.call should already have statically asserted all
578                     // event handlers are of the types above
579                     static assert(0, "Failed to cover all event handler function signature cases");
580                 }
581             }
582 
583             if (inFiber)
584             {
585                 import kameloso.constants : BufferSize;
586                 import kameloso.thread : CarryingFiber;
587                 import core.thread : Fiber;
588 
589                 auto fiber = new CarryingFiber!IRCEvent(
590                     &call,
591                     BufferSize.fiberStack);
592                 fiber.payload = replay.event;
593                 fiber.call();
594 
595                 if (fiber.state == Fiber.State.TERM)
596                 {
597                     // Ended immediately, so just destroy
598                     destroy(fiber);
599                 }
600             }
601             else
602             {
603                 call();
604             }
605 
606             return;
607         }
608 
609         version(ExplainReplay) explainRefuse();
610     }
611 
612     return Replay(&replayDg, event, permissionsRequired, caller);
613 }
614 
615 
616 // rehashUsers
617 /++
618     Rehashes a plugin's users, both the ones in the
619     [kameloso.plugins.common.core.IRCPluginState.users|IRCPluginState.users]
620     associative array and the ones in each [dialect.defs.IRCChannel.users] associative arrays.
621 
622     This optimises lookup and should be done every so often.
623 
624     Params:
625         plugin = The current [kameloso.plugins.common.core.IRCPlugin|IRCPlugin].
626         channelName = Optional name of the channel to rehash for. If none given
627             it will rehash the main
628             [kameloso.plugins.common.core.IRCPluginState.users|IRCPluginState.users]
629             associative array instead.
630  +/
631 void rehashUsers(IRCPlugin plugin, const string channelName = string.init)
632 {
633     if (!channelName.length)
634     {
635         plugin.state.users = plugin.state.users.rehash();
636     }
637     else if (auto channel = channelName in plugin.state.channels)
638     {
639         // created in `onChannelAwarenessSelfjoin`
640         channel.users = channel.users.rehash();
641     }
642 }
643 
644 
645 // nameOf
646 /++
647     Returns either the nickname or the display name of a user, depending on whether the
648     display name is known or not.
649 
650     If not version `TwitchSupport` then it always returns the nickname.
651 
652     Params:
653         user = [dialect.defs.IRCUser|IRCUser] to examine.
654 
655     Returns:
656         The nickname of the user if there is no alias known, else the alias.
657  +/
658 pragma(inline, true)
659 auto nameOf(const IRCUser user) pure @safe nothrow @nogc
660 {
661     version(TwitchSupport)
662     {
663         return user.displayName.length ? user.displayName : user.nickname;
664     }
665     else
666     {
667         return user.nickname;
668     }
669 }
670 
671 ///
672 unittest
673 {
674     version(TwitchSupport)
675     {
676         {
677             IRCUser user;
678             user.nickname = "joe";
679             user.displayName = "Joe";
680             assert(nameOf(user) == "Joe");
681         }
682         {
683             IRCUser user;
684             user.nickname = "joe";
685             assert(nameOf(user) == "joe");
686         }
687     }
688     {
689         IRCUser user;
690         user.nickname = "joe";
691         assert(nameOf(user) == "joe");
692     }
693 }
694 
695 
696 // nameOf
697 /++
698     Returns either the nickname or the display name of a user, depending on whether the
699     display name is known or not. Overload that looks up the passed nickname in
700     the passed plugin's `users` associative array of [dialect.defs.IRCUser|IRCUser]s.
701 
702     If not version `TwitchSupport` then it always returns the nickname.
703 
704     Params:
705         plugin = The current [kameloso.plugins.common.core.IRCPlugin|IRCPlugin], whatever it is.
706         specified = The name of a user to look up.
707 
708     Returns:
709         The nickname of the user if there is no alias known, else the alias.
710  +/
711 auto nameOf(const IRCPlugin plugin, const string specified) pure @safe nothrow @nogc
712 {
713     version(TwitchSupport)
714     {
715         if (plugin.state.server.daemon == IRCServer.Daemon.twitch)
716         {
717             import lu.string : beginsWith;
718 
719             immutable nickname = specified.beginsWith('@') ?
720                 specified[1..$] :
721                 specified;
722 
723             if (const user = nickname in plugin.state.users)
724             {
725                 return nameOf(*user);
726             }
727         }
728     }
729 
730     return specified;
731 }
732 
733 
734 // idOf
735 /++
736     Returns either the nickname or the account of a user, depending on whether
737     the account is known.
738 
739     Params:
740         user = [dialect.defs.IRCUser|IRCUser] to examine.
741 
742     Returns:
743         The nickname or account of the passed user.
744  +/
745 pragma(inline, true)
746 auto idOf(const IRCUser user) pure @safe nothrow @nogc
747 in (user.nickname.length, "Tried to get `idOf` a user with an empty nickname")
748 {
749     return user.account.length ? user.account : user.nickname;
750 }
751 
752 
753 // idOf
754 /++
755     Returns either the nickname or the account of a user, depending on whether
756     the account is known. Overload that looks up the passed nickname in
757     the passed plugin's `users` associative array of [dialect.defs.IRCUser|IRCUser]s.
758 
759     Merely wraps [getUser] with [idOf].
760 
761     Params:
762         plugin = The current [kameloso.plugins.common.core.IRCPlugin|IRCPlugin], whatever it is.
763         nickname = The name of a user to look up.
764 
765     Returns:
766         The nickname or account of the passed user, or the passed nickname if
767         nothing was found.
768 
769     See_Also:
770         [getUser]
771  +/
772 auto idOf(IRCPlugin plugin, const string nickname)
773 {
774     immutable user = getUser(plugin, nickname);
775     return idOf(user);
776 }
777 
778 ///
779 unittest
780 {
781     final class MyPlugin : IRCPlugin
782     {
783         mixin IRCPluginImpl;
784     }
785 
786     IRCPluginState state;
787     IRCPlugin plugin = new MyPlugin(state);
788 
789     IRCUser newUser;
790     newUser.nickname = "nickname";
791     plugin.state.users["nickname"] = newUser;
792 
793     immutable nickname = idOf(plugin, "nickname");
794     assert((nickname == "nickname"), nickname);
795 
796     plugin.state.users["nickname"].account = "account";
797     immutable account = idOf(plugin, "nickname");
798     assert((account == "account"), account);
799 }
800 
801 
802 // getUser
803 /++
804     Retrieves an [dialect.defs.IRCUser|IRCUser] from the passed plugin's `users`
805     associative array. If none exists, returns a minimally viable
806     [dialect.defs.IRCUser|IRCUser] with the passed nickname as its only value.
807 
808     On Twitch, if no user was found, it additionally tries to look up the passed
809     nickname as if it was a display name.
810 
811     Params:
812         plugin = The current [kameloso.plugins.common.core.IRCPlugin|IRCPlugin], whatever it is.
813         specified = The name of a user to look up.
814 
815     Returns:
816         An [dialect.defs.IRCUser|IRCUser] that matches the passed nickname, from the
817         passed plugin's arrays. A minimally viable [dialect.defs.IRCUser|IRCUser] if
818         none was found.
819  +/
820 auto getUser(IRCPlugin plugin, const string specified)
821 {
822     version(TwitchSupport)
823     {
824         import lu.string : beginsWith;
825 
826         immutable isTwitch = (plugin.state.server.daemon == IRCServer.Daemon.twitch);
827         immutable nickname = (isTwitch && specified.beginsWith('@')) ?
828             specified[1..$] :
829             specified;
830     }
831     else
832     {
833         alias nickname = specified;
834     }
835 
836     if (auto user = nickname in plugin.state.users)
837     {
838         return *user;
839     }
840 
841     version(TwitchSupport)
842     {
843         if (isTwitch)
844         {
845             foreach (user; plugin.state.users)
846             {
847                 if (user.displayName == nickname)
848                 {
849                     return user;
850                 }
851             }
852 
853             // No match, populate a new user and return it
854             IRCUser user;
855             user.nickname = nickname;
856             user.account = nickname;
857             user.class_ = IRCUser.Class.registered;
858             //user.displayName = nickname;
859             return user;
860         }
861     }
862 
863     IRCUser user;
864     user.nickname = nickname;
865     return user;
866 }
867 
868 ///
869 unittest
870 {
871     final class MyPlugin : IRCPlugin
872     {
873         mixin IRCPluginImpl;
874     }
875 
876     IRCPluginState state;
877     IRCPlugin plugin = new MyPlugin(state);
878 
879     IRCUser newUser;
880     newUser.nickname = "nickname";
881     newUser.displayName = "NickName";
882     plugin.state.users["nickname"] = newUser;
883 
884     immutable sameUser = getUser(plugin, "nickname");
885     assert(newUser == sameUser);
886 
887     version(TwitchSupport)
888     {
889         plugin.state.server.daemon = IRCServer.Daemon.twitch;
890         immutable sameAgain = getUser(plugin, "NickName");
891         assert(newUser == sameAgain);
892     }
893 }
894 
895 
896 // EventURLs
897 /++
898     A struct imitating a [std.typecons.Tuple], used to communicate the
899     need for a Webtitles lookup.
900 
901     We shave off a few megabytes of required compilation memory by making it a
902     struct instead of a tuple.
903  +/
904 version(WithWebtitlesPlugin)
905 version(WithTwitchPlugin)
906 struct EventURLs
907 {
908     /// The [dialect.defs.IRCEvent|IRCEvent] that should trigger a Webtitles lookup.
909     IRCEvent event;
910 
911     /// The URLs discovered inside [dialect.defs.IRCEvent.content|IRCEvent.content].
912     string[] urls;
913 }
914 
915 
916 // pluginFileBaseName
917 /++
918     Returns a meaningful basename of a plugin filename.
919 
920     This is preferred over use of [std.path.baseName] because some plugins are
921     nested in their own directories. The basename of `plugins/twitch/base.d` is
922     `base.d`, much like that of `plugins/printer/base.d` is.
923 
924     With this we get `twitch/base.d` and `printer/base.d` instead, while still
925     getting `oneliners.d`.
926 
927     Params:
928         filename = Full path to a plugin file.
929 
930     Returns:
931         A meaningful basename of the passed filename.
932  +/
933 auto pluginFileBaseName(const string filename)
934 in (filename.length, "Empty plugin filename passed to `pluginFileBaseName`")
935 {
936     return pluginFilenameSlicerImpl(filename, No.getPluginName);
937 }
938 
939 ///
940 unittest
941 {
942     {
943         version(Posix) enum filename = "plugins/oneliners.d";
944         else /*version(Windows)*/ enum filename = "plugins\\oneliners.d";
945         immutable expected = "oneliners.d";
946         immutable actual = pluginFileBaseName(filename);
947         assert((expected == actual), actual);
948     }
949     {
950         version(Posix)
951         {
952             enum filename = "plugins/twitch/base.d";
953             immutable expected = "twitch/base.d";
954         }
955         else /*version(Windows)*/
956         {
957             enum filename = "plugins\\twitch\\base.d";
958             immutable expected = "twitch\\base.d";
959         }
960 
961         immutable actual = pluginFileBaseName(filename);
962         assert((expected == actual), actual);
963     }
964     {
965         version(Posix) enum filename = "plugins/counters.d";
966         else /*version(Windows)*/ enum filename = "plugins\\counters.d";
967         immutable expected = "counters.d";
968         immutable actual = pluginFileBaseName(filename);
969         assert((expected == actual), actual);
970     }
971 }
972 
973 
974 // pluginNameOfFilename
975 /++
976     Returns the name of a plugin based on its filename.
977 
978     This is preferred over slicing [std.path.baseName] because some plugins are
979     nested in their own directories. The basename of `plugins/twitch/base.d` is
980     `base.d`, much like that of `plugins/printer/base.d` is.
981 
982     With this we get `twitch` and `printer` instead, while still getting `oneliners`.
983 
984     Params:
985         filename = Full path to a plugin file.
986 
987     Returns:
988         The name of the plugin, based on its filename.
989  +/
990 auto pluginNameOfFilename(const string filename)
991 in (filename.length, "Empty plugin filename passed to `pluginNameOfFilename`")
992 {
993     return pluginFilenameSlicerImpl(filename, Yes.getPluginName);
994 }
995 
996 ///
997 unittest
998 {
999     {
1000         version(Posix) enum filename = "plugins/oneliners.d";
1001         else /*version(Windows)*/ enum filename = "plugins\\oneliners.d";
1002         immutable expected = "oneliners";
1003         immutable actual = pluginNameOfFilename(filename);
1004         assert((expected == actual), actual);
1005     }
1006     {
1007         version(Posix) enum filename = "plugins/twitch/base.d";
1008         else /*version(Windows)*/ enum filename = "plugins\\twitch\\base.d";
1009         immutable expected = "twitch";
1010         immutable actual = pluginNameOfFilename(filename);
1011         assert((expected == actual), actual);
1012     }
1013     {
1014         version(Posix) enum filename = "plugins/counters.d";
1015         else /*version(Windows)*/ enum filename = "plugins\\counters.d";
1016         immutable expected = "counters";
1017         immutable actual = pluginNameOfFilename(filename);
1018         assert((expected == actual), actual);
1019     }
1020 }
1021 
1022 
1023 // pluginFilenameSlicerImpl
1024 /++
1025     Implementation function, code shared between [pluginFileBaseName] and
1026     [pluginNameOfFilename].
1027 
1028     Params:
1029         filename = Full path to a plugin file.
1030         getPluginName = Whether we want the plugin name or the plugin file "basename".
1031 
1032     Returns:
1033         The name of the plugin or its "basename", based on its filename and the
1034         `getPluginName` parameter.
1035  +/
1036 private auto pluginFilenameSlicerImpl(const string filename, const Flag!"getPluginName" getPluginName)
1037 in (filename.length, "Empty plugin filename passed to `pluginFilenameSlicerImpl`")
1038 {
1039     import std.path : dirSeparator;
1040     import std.string : indexOf;
1041 
1042     string slice = filename;  // mutable
1043     size_t pos = slice.indexOf(dirSeparator);
1044 
1045     while (pos != -1)
1046     {
1047         if (slice[pos+1..$] == "base.d")
1048         {
1049             return getPluginName ? slice[0..pos] : slice;
1050         }
1051         slice = slice[pos+1..$];
1052         pos = slice.indexOf(dirSeparator);
1053     }
1054 
1055     return getPluginName ? slice[0..$-2] : slice;
1056 }