1 /++
2     The Seen plugin implements "seen"; the ability for someone to
3     query when a given nickname was last encountered online.
4 
5     We will implement this by keeping an internal `long[string]` associative
6     array of timestamps keyed by nickname. Whenever we see a user do something,
7     we will update his or her timestamp to the current time. We'll save this
8     array to disk when closing the program and read it from file when starting
9     it, as well as saving occasionally once every few (compile time-configurable)
10     minutes.
11 
12     We will rely on the
13     [kameloso.plugins.services.chanqueries.ChanQueriesService|ChanQueriesService] to query
14     channels for full lists of users upon joining new ones, including the
15     ones we join upon connecting. Elsewise, a completely silent user will never
16     be recorded as having been seen, as they would never be triggering any of
17     the functions we define to listen to. (There's a setting to ignore non-chatty
18     events, as we'll see later.)
19 
20     kameloso does primarily not use callbacks, but instead annotates functions
21     with `UDA`s of IRC event *types*. When an event is incoming it will trigger
22     the function(s) annotated with its type.
23 
24     Callback delegates and [core.thread.fiber.Fiber|Fiber]s *are* supported but are not
25     the primary way to trigger event handler functions. Such can however
26     be registered to process on incoming events, or scheduled with a reasonably
27     high degree of precision.
28 
29     See_Also:
30         https://github.com/zorael/kameloso/wiki/Current-plugins#seen,
31         [kameloso.plugins.common.core],
32         [kameloso.plugins.common.misc]
33 
34     Copyright: [JR](https://github.com/zorael)
35     License: [Boost Software License 1.0](https://www.boost.org/users/license.html)
36 
37     Authors:
38         [JR](https://github.com/zorael)
39  +/
40 module kameloso.plugins.seen;
41 
42 // We only want to compile this if we're compiling specifically this plugin.
43 version(WithSeenPlugin):
44 
45 // We need the bits to register the plugin to be automatically instantiated.
46 private import kameloso.plugins;
47 
48 // We need the definition of an [kameloso.plugins.core.IRCPlugin|IRCPlugin] and other crucial things.
49 private import kameloso.plugins.common.core;
50 
51 // Awareness mixins, for plumbing.
52 private import kameloso.plugins.common.awareness : ChannelAwareness, UserAwareness;
53 
54 // Likewise [dialect.defs], for the definitions of an IRC event.
55 private import dialect.defs;
56 
57 // [kameloso.common] for the global logger instance and the rehashing AA.
58 private import kameloso.common : RehashingAA, logger;
59 
60 // [std.datetime.systime] for the [std.datetime.systime.Clock|Clock], to update times with.
61 private import std.datetime.systime : Clock;
62 
63 // [std.typecons] for [std.typecons.Flag|Flag] and its friends.
64 private import std.typecons : Flag, No, Yes;
65 
66 // [core.time] for [core.time.hours|hours], with which we can delay some actions.
67 private import core.time : hours;
68 
69 
70 version(OmniscientSeen)
71 {
72     // omniscientChannelPolicy
73     /++
74         The [kameloso.plugins.common.core.ChannelPolicy|ChannelPolicy] annotation dictates
75         whether or not an annotated function should be called based on the *channel*
76         the event took place in, if applicable.
77 
78         The three policies are
79         [kameloso.plugins.common.core.ChannelPolicy.home|ChannelPolicy.home],
80         with which only events in channels in the
81         [kameloso.pods.IRCBot.homeChannels|IRCBot.homeChannels]
82         array will be allowed to trigger it;
83         [kameloso.plugins.common.core.ChannelPolicy.guest|ChannelPolicy.guest]
84         with which only events outside of such home channels will be allowed to trigger;
85         or [kameloso.plugins.common.core.ChannelPolicy.any|ChannelPolicy.any],
86         in which case anywhere goes.
87 
88         For events that don't correspond to a channel (such as
89         [dialect.defs.IRCEvent.Type.QUERY|QUERY]) the setting doesn't apply and is ignored.
90 
91         Thus this [omniscientChannelPolicy] enum constant is a compile-time setting
92         for all event handlers where whether a channel is a home or not is of
93         interest (or even applies). Put in a version block like this it allows
94         us to control the plugin's behaviour via `dub` build configurations.
95      +/
96     private enum omniscientChannelPolicy = ChannelPolicy.any;
97 }
98 else
99 {
100     /// Ditto
101     private enum omniscientChannelPolicy = ChannelPolicy.home;
102 }
103 
104 
105 /++
106     [kameloso.plugins.common.awareness.UserAwareness|UserAwareness] is a mixin
107     template; it proxies to a few functions defined in [kameloso.plugins.common.awareness]
108     to deal with common book-keeping that every plugin *that wants to keep track
109     of users* need. If you don't want to track which users you have seen (and are
110     visible to you now), you don't need this.
111 
112     Additionally it implicitly mixes in
113     [kameloso.plugins.common.awareness.MinimalAuthentication|MinimalAuthentication],
114     needed as soon as you have any [kameloso.plugins.common.core.PrefixPolicy|PrefixPolicy] checks.
115  +/
116 mixin UserAwareness;
117 
118 
119 /++
120     Complementary to [kameloso.plugins.common.awareness.UserAwareness|UserAwareness] is
121     [kameloso.plugins.common.awareness.ChannelAwareness|ChannelAwareness], which
122     will add in book-keeping about the channels the bot is in, their topics, modes,
123     and list of participants. Channel awareness requires user awareness, but not
124     the other way around.
125 
126     Depending on the value of [omniscientChannelPolicy] we may want it to limit
127     the amount of tracked users to people in our home channels.
128  +/
129 mixin ChannelAwareness!omniscientChannelPolicy;
130 
131 
132 /++
133     Mixes in a module constructor that registers this module's plugin to be
134     instantiated on program startup/connect.
135  +/
136 mixin PluginRegistration!SeenPlugin;
137 
138 
139 /+
140     Most of the module can (and ideally should) be kept private. Our surface
141     area here will be restricted to only one [kameloso.plugins.common.core.IRCPlugin|IRCPlugin]
142     class, and the usual pattern used is to have the private bits first and that
143     public class last. We'll turn that around here to make it easier to visually parse.
144  +/
145 
146 public:
147 
148 
149 // SeenPlugin
150 /++
151     This is your plugin to the outside world, the only thing publicly visible in the
152     entire module. It only serves as a way of proxying calls to our top-level
153     private functions, as well as to house plugin-specific and -private variables that we want
154     to keep out of top-level scope for the sake of modularity. If the only state
155     is in the plugin, several plugins of the same kind can technically be run
156     alongside each other, which would allow for several bots to be run in
157     parallel. This is not yet supported but there's fundamentally nothing stopping it.
158 
159     As such it houses this plugin's *state*, notably its instance of
160     [SeenSettings] and its [kameloso.plugins.common.core.IRCPluginState|IRCPluginState].
161 
162     The [kameloso.plugins.common.core.IRCPluginState|IRCPluginState] is a struct housing various
163     variables that together make up the plugin's state. This is where
164     information is kept about the bot, the server, and some metathings allowing
165     us to send messages to the server. We don't define it here; we mix it in
166     later with the [kameloso.plugins.common.core.IRCPluginImpl|IRCPluginImpl] mixin.
167 
168     ---
169     struct IRCPluginState
170     {
171         IRCClient client;
172         IRCServer server;
173         IRCBot bot;
174         CoreSettings settings;
175         ConnectionSettings connSettings;
176         Tid mainThread;
177         IRCUser[string] users;
178         IRCChannel[string] channels;
179         Replay[][string] pendingReplays;
180         bool hasPendingReplays;
181         Replay[] readyReplays;
182         Fiber[][] awaitingFibers;
183         void delegate(IRCEvent)[][] awaitingDelegates;
184         ScheduledFiber[] scheduledFibers;
185         ScheduledDelegate[] scheduledDelegates;
186         long nextScheduledTimestamp;
187         void updateScheule();
188         Update updates;
189         bool* abort;
190     }
191     ---
192 
193     * [kameloso.plugins.common.core.IRCPluginState.client|IRCPluginState.client]
194         houses information about the client itself, such as your nickname and
195         other things related to an IRC client.
196 
197     * [kameloso.plugins.common.core.IRCPluginState.server|IRCPluginState.server]
198         houses information about the server you're connected to.
199 
200     * [kameloso.plugins.common.core.IRCPluginState.bot|IRCPluginState.bot] houses
201         information about things that relate to an IRC bot, like which channels
202         to join, which home channels to operate in, the list of administrator accounts, etc.
203 
204     * [kameloso.plugins.common.core.IRCPluginState.settings|IRCPluginState.settings]
205         is a copy of the "global" [kameloso.pods.CoreSettings|CoreSettings],
206         which contains information about how the bot should output text, whether
207         or not to always save to disk upon program exit, and some other program-wide settings.
208 
209     * [kameloso.plugins.common.core.IRCPluginState.connSettings|IRCPluginState.connSettings]
210         is like [kameloso.plugins.common.core.IRCPluginState.settings|IRCPluginState.settings],
211         except for values relating to the connection to the server; whether to
212         use IPv6, paths to any certificates, and the such.
213 
214     * [kameloso.plugins.common.core.IRCPluginState.mainThread|IRCPluginState.mainThread]
215         is the [std.concurrency.Tid|*thread ID*] of the thread running the main loop.
216         We indirectly use it to send strings to the server by way of concurrency
217         messages, but it is usually not something you will have to deal with directly.
218 
219     * [kameloso.plugins.common.core.IRCPluginState.users|IRCPluginState.users]
220         is an associative array keyed with users' nicknames. The value to that key is an
221         [dialect.defs.IRCUser|IRCUser] representing that user in terms of nickname,
222         address, ident, services account name, and much more. This is a way to keep track of
223         users by more than merely their name. It is however not saved at the end
224         of the program; as everything else it is merely state and transient.
225 
226     * [kameloso.plugins.common.core.IRCPluginState.channels|IRCPluginState.channels]
227         is another associative array, this one with all the known channels keyed
228         by their names. This way we can access detailed information about any
229         known channel, given only their name.
230 
231     * [kameloso.plugins.common.core.IRCPluginState.pendingReplays|IRCPluginState.pendingReplays]
232         is also an associative array into which we place [kameloso.plugins.common.core.Replay|Replay]s.
233         The main loop will pick up on these and call WHOIS on the nickname in the key.
234         A [kameloso.plugins.common.core.Replay|Replay] is otherwise just an
235         [dialect.defs.IRCEvent|IRCEvent] to be played back when the WHOIS results
236         return, as well as a delegate that invokes the function that was originally
237         to be called. Constructing a [kameloso.plugins.common.core.Replay|Replay] is
238         all wrapped in a function [kameloso.plugins.common.misc.enqueue|enqueue], with the
239         queue management handled behind the scenes.
240 
241     * [kameloso.plugins.common.core.IRCPluginState.hasPendingReplays|IRCPluginState.hasPendingReplays]
242         is merely a bool of whether or not there currently are any
243         [kameloso.plugins.common.core.Replay|Replay]s in
244         [kameloso.plugins.common.core.IRCPluginState.pendingReplays|IRCPluginState.pendingReplays],
245         cached to avoid associative array length lookups.
246 
247     * [kameloso.plugins.common.core.IRCPluginState.readyReplays|IRCPluginState.readyReplays]
248         is an array of [kameloso.plugins.common.core.Replay|Replay]s that have
249         seen their WHOIS request issued and the result received. Moving one from
250         [kameloso.plugins.common.core.IRCPluginState.pendingReplays|IRCPluginState.pendingReplays]
251         to [kameloso.plugins.common.core.IRCPluginState.readyReplays|IRCPluginState.readyReplays]
252         will make the main loop pick it up, *update* the [dialect.defs.IRCEvent|IRCEvent]
253         stored within it with what we now know of the sender and/or target, and
254         then replay the event by invoking its delegate.
255 
256     * [kameloso.plugins.common.core.IRCPluginState.awaitingFibers|IRCPluginState.awaitingFibers]
257         is an array of [core.thread.fiber.Fiber|Fiber]s indexed by [dialect.defs.IRCEvent.Type]s'
258         numeric values. Fibers in the array of a particular event type will be
259         executed the next time such an event is incoming. Think of it as Fiber callbacks.
260 
261     * [kameloso.plugins.common.core.IRCPluginState.awaitingDelegates|IRCPluginState.awaitingDelegates]
262         is literally an array of callback delegates, to be triggered when an event
263         of a matching type comes along.
264 
265     * [kameloso.plugins.common.core.IRCPluginState.scheduledFibers|IRCPluginState.scheduledFibers]
266         is also an array of [core.thread.fiber.Fiber|Fiber]s, but not one keyed
267         on or indexed by event types. Instead they are tuples of a
268         [core.thread.fiber.Fiber|Fiber] and a `long` timestamp of when they should be run.
269         Use [kameloso.plugins.common.delayawait.delay|delay] to enqueue.
270 
271     * [kameloso.plugins.common.core.IRCPluginState.scheduledDelegates|IRCPluginState.scheduledDelegates]
272         is likewise an array of delegates, to be triggered at a later point in time.
273 
274     * [kameloso.plugins.common.core.IRCPluginState.nextScheduledTimestamp|IRCPluginState.nextScheduledFibers]
275         is also a UNIX timestamp, here of when the next [kameloso.thread.ScheduledFiber|ScheduledFiber]
276         in [kameloso.plugins.common.core.IRCPluginState.scheduledFibers|IRCPluginState.scheduledFibers]
277         *or* the next [kameloso.thread.ScheduledDelegate|ScheduledDelegate] in
278         [kameloso.plugins.common.core.IRCPluginState.scheduledDelegates|IRCPluginState.scheduledDelegates]
279         is due to be processed. Caching it here means we won't have to walk through
280         the arrays to find out as often.
281 
282     * [kameloso.plugins.common.core.IRCPluginState.updateSchedule|IRCPluginState.updateSchedule]
283         merely iterates all scheduled fibers and delegates, caching the time at
284         which the next one should trigger in
285         [kameloso.plugins.common.core.IRCPluginState.nextScheduledTimestamp|IRCPluginState.nextScheduledFibers].
286 
287     * [kameloso.plugins.common.core.IRCPluginState.updates|IRCPluginState.updates]
288         is a bitfield which represents what aspect of the bot was *changed*
289         during processing or postprocessing. If any of the bits are set, represented
290         by the enum values of [kameloso.plugins.common.core.IRCPluginState.Updates|IRCPluginState.Updates],
291         the main loop will pick up on it and propagate it to other plugins.
292         If these flags are not set, changes will never leave the plugin and may
293         be overwritten by other plugins. It is mostly for internal use.
294 
295     * [kameloso.plugins.common.core.IRCPluginState.abort|IRCPluginState.abort]
296         is a pointer to the global abort bool. When this is set, it signals the
297         rest of the program that we want to terminate cleanly.
298  +/
299 final class SeenPlugin : IRCPlugin
300 {
301 private:  // Module-level private.
302 
303     // seenSettings
304     /++
305         An instance of *settings* for the Seen plugin. We will define this
306         later. The members of it will be saved to and loaded from the
307         configuration file, for use in our module.
308      +/
309     SeenSettings seenSettings;
310 
311 
312     // seenUsers
313     /++
314         Our associative array (AA) of seen users; a dictionary keyed with
315         users' nicknames and with values that are UNIX timestamps, denoting when
316         that user was last *seen* online.
317 
318         Example:
319         ---
320         seenUsers["joe"] = Clock.currTime.toUnixTime;
321         // ..later..
322         immutable now = Clock.currTime.toUnixTime;
323         writeln("Seconds since we last saw joe: ", (now - seenUsers["joe"]));
324         ---
325      +/
326     RehashingAA!(string, long) seenUsers;
327 
328 
329     // seenFile
330     /++
331         The filename to which to persistently store our list of seen users
332         between executions of the program.
333 
334         This is only the basename of the file. It will be completed with a path
335         to the default (or specified) resource directory, which varies by
336         platform. Expect this variable to have values like
337         "`/home/user/.local/share/kameloso/servers/irc.libera.chat/seen.json`"
338         after the plugin has been instantiated.
339      +/
340     @Resource string seenFile = "seen.json";
341 
342 
343     // timeBetweenSaves
344     /++
345         The amount of time after which seen users should be saved to disk.
346      +/
347     static immutable timeBetweenSaves = 1.hours;
348 
349 
350     // IRCPluginImpl
351     /++
352         This mixes in functions that fully implement an
353         [kameloso.plugins.common.core.IRCPlugin|IRCPlugin]. They don't do much by themselves
354         other than call the module's functions, as well as implement things like
355         functions that return the plugin's name, its list of bot command words, etc.
356         It does this by introspecting the module and implementing itself as it sees fit.
357 
358         This includes the functions that call the top-level event handler functions
359         on incoming events.
360 
361         Seen from any other module, this module is a big block of private things
362         they can't see, plus this visible plugin class. By having this class
363         pass on things to the private functions we limit the surface area of
364         the plugin to be really small.
365      +/
366     mixin IRCPluginImpl;
367 
368 
369     import kameloso.plugins.common.mixins : MessagingProxy;
370 
371     // MessagingProxy
372     /++
373         This mixin adds shorthand functions to proxy calls to
374         [kameloso.messaging] functions, *partially applied* with the main thread ID,
375         so they can easily be called with knowledge only of the plugin symbol.
376 
377         ---
378         plugin.chan("#d", "Hello world!");
379         plugin.query("kameloso", "Hello you!");
380 
381         with (plugin)
382         {
383             chan("#d", "This is convenient");
384             query("kameloso", "No need to specify plugin.state.mainThread");
385         }
386         ---
387      +/
388     mixin MessagingProxy;
389 }
390 
391 
392 /+
393     The rest will be private.
394  +/
395 private:
396 
397 
398 // SeenSettings
399 /++
400     We want our plugin to be *configurable* with a section for itself in the
401     configuration file. For this purpose we create a "Settings" struct housing
402     our configurable bits, which we already made an instance of in [SeenPlugin].
403 
404     If it's annotated with [kameloso.plugins.common.core.Settings|Settings], the
405     wizardry will pick it up and each member of the struct will be given its own
406     line in the configuration file. Note that not all types are supported, such as
407     associative arrays or nested structs/classes.
408 
409     If the name ends with "Settings", that will be stripped from its section
410     header in the file. Hence, this plugin's [SeenSettings] will get the header
411     `[Seen]`.
412  +/
413 @Settings struct SeenSettings
414 {
415     /++
416         Toggles whether or not the plugin should react to events at all.
417         The @[kameloso.plugins.common.core.Enabler|Enabler] annotation makes it special and
418         lets us easily enable or disable the plugin without having checks everywhere.
419      +/
420     @Enabler bool enabled = true;
421 
422     /++
423         Toggles whether or not non-chat events, such as
424         [dialect.defs.IRCEvent.Type.JOIN|JOIN]s,
425         [dialect.defs.IRCEvent.Type.PART|PART]s and the such, should be considered
426         as observations. If set, only chatty events will count as being seen.
427      +/
428     bool ignoreNonChatEvents = false;
429 }
430 
431 
432 // onSomeAction
433 /++
434     Whenever a user does something, record this user as having been seen at the
435     current time.
436 
437     This function will be called whenever an [dialect.defs.IRCEvent|IRCEvent] is
438     being processed of the [dialect.defs.IRCEvent.Type|IRCEvent.Type]s that we annotate
439     the function with.
440 
441     The [kameloso.plugins.common.core.IRCEventHandler.chainable|IRCEventHandler.chainable]
442     annotations mean that the plugin will also process other functions in this
443     module with the same [dialect.defs.IRCEvent.Type|IRCEvent.Type] annotations,
444     even if this one matched. The default is otherwise that it will end early
445     after one match and proceed to the next plugin, but this doesn't ring well
446     with catch-all functions like these. It's sensible to save
447     [kameloso.plugins.common.core.IRCEventHandler.chainable|IRCEventHandler.chainable]
448     only for the modules and functions that actually need it.
449 
450     The [kameloso.plugins.common.core.IRCEventHandler.requiredPermissions|IRCEventHandler.requiredPermissions]
451     annotation dictates who is authorised to trigger the function. It has six
452     policies, in increasing order of importance:
453     [kameloso.plugins.common.core.Permissions.ignore|Permissions.ignore],
454     [kameloso.plugins.common.core.Permissions.anyone|Permissions.anyone],
455     [kameloso.plugins.common.core.Permissions.registered|Permissions.registered],
456     [kameloso.plugins.common.core.Permissions.whitelist|Permissions.whitelist],
457     [kameloso.plugins.common.core.Permissions.elevated|Permissions.elevated],
458     [kameloso.plugins.common.core.Permissions.operator|Permissions.operator],
459     [kameloso.plugins.common.core.Permissions.staff|Permissions.staff] and
460     [kameloso.plugins.common.core.Permissions.admin|Permissions.admin].
461 
462     * [kameloso.plugins.common.core.Permissions.ignore|Permissions.ignore] will
463         let precisely anyone trigger it, without looking them up.
464 
465     * [kameloso.plugins.common.core.Permissions.anyone|Permissions.anyone] will
466         let anyone trigger it, but only after having looked them up, allowing
467         for blacklisting people.
468 
469     * [kameloso.plugins.common.core.Permissions.registered|Permissions.registered]
470         will let anyone logged into a services account trigger it, provided they
471         are not blacklisted.
472 
473     * [kameloso.plugins.common.core.Permissions.whitelist|Permissions.whitelist]
474         will only allow users in the whitelist section of the `users.json`
475         resource file, provided they are also not blacklisted. Consider this to
476         correspond to "regulars" in the channel.
477 
478     * [kameloso.plugins.common.core.Permissions.elevated|Permissions.elevated]
479         will also only allow users in the whitelist section of the `users.json`
480         resource file, provided they are also not blacklisted. Consider this to
481         correspond to VIPs in the channel.
482 
483     * [kameloso.plugins.common.core.Permissions.operator|Permissions.operator]
484         will only allow users in the operator section of the `users.json`
485         resource file. Consider this to correspond to "moderators" in the channel.
486 
487     * [kameloso.plugins.common.core.Permissions.staff|Permissions.staff] will
488         only allow users in the staff section of the `users.json` resource file.
489         Consider this to correspond to channel owners.
490 
491     * [kameloso.plugins.common.core.Permissions.admin|Permissions.admin] will
492         allow only you and your other superuser administrators, as defined in
493         the configuration file. This is a program-wide permission and will apply
494         to all channels. Consider it to correspond to bot system operators.
495 
496     In the case of
497     [kameloso.plugins.common.core.Permissions.whitelist|Permissions.whitelist],
498     [kameloso.plugins.common.core.Permissions.elevated|Permissions.elevated],
499     [kameloso.plugins.common.core.Permissions.operator|Permissions.operator],
500     [kameloso.plugins.common.core.Permissions.staff|Permissions.staff] and
501     [kameloso.plugins.common.core.Permissions.admin|Permissions.admin] it will
502     look you up and compare your *services account name* to those known good
503     before doing anything. In the case of
504     [kameloso.plugins.common.core.Permissions.registered|Permissions.registered],
505     merely being logged in is enough. In the case of
506     [kameloso.plugins.common.core.Permissions.anyone|Permissions.anyone], the
507     WHOIS results won't matter and it will just let it pass, but it will check
508     all the same so as to be able to apply the blacklist.
509     In the other cases, if you aren't logged into services or if your account
510     name isn't included in the lists, the function will not trigger.
511 
512     This particular function doesn't care at all, so it is
513     [kameloso.plugins.common.core.Permissions.ignore|Permissions.ignore].
514 
515     The [kameloso.plugins.common.core.ChannelPolicy|ChannelPolicy] here is the same
516     [omniscientChannelPolicy] we defined earlier, versioned to have a different
517     value based on the dub build configuration. By default, it's
518     [kameloso.plugins.common.core.ChannelPolicy.home|ChannelPolicy.home].
519  +/
520 @(IRCEventHandler()
521     .onEvent(IRCEvent.Type.CHAN)
522     .onEvent(IRCEvent.Type.QUERY)
523     .onEvent(IRCEvent.Type.EMOTE)
524     .onEvent(IRCEvent.Type.JOIN)
525     .onEvent(IRCEvent.Type.PART)
526     .onEvent(IRCEvent.Type.MODE)
527     .onEvent(IRCEvent.Type.TWITCH_SUB)
528     .onEvent(IRCEvent.Type.TWITCH_SUBGIFT)
529     .onEvent(IRCEvent.Type.TWITCH_CHEER)
530     .onEvent(IRCEvent.Type.TWITCH_REWARDGIFT)
531     .onEvent(IRCEvent.Type.TWITCH_GIFTCHAIN)
532     .onEvent(IRCEvent.Type.TWITCH_BULKGIFT)
533     .onEvent(IRCEvent.Type.TWITCH_SUBUPGRADE)
534     .onEvent(IRCEvent.Type.TWITCH_CHARITY)
535     .onEvent(IRCEvent.Type.TWITCH_BITSBADGETIER)
536     .onEvent(IRCEvent.Type.TWITCH_RITUAL)
537     .onEvent(IRCEvent.Type.TWITCH_EXTENDSUB)
538     .onEvent(IRCEvent.Type.TWITCH_GIFTRECEIVED)
539     .onEvent(IRCEvent.Type.TWITCH_PAYFORWARD)
540     .onEvent(IRCEvent.Type.TWITCH_RAID)
541     .onEvent(IRCEvent.Type.TWITCH_CROWDCHANT)
542     .onEvent(IRCEvent.Type.TWITCH_ANNOUNCEMENT)
543     .onEvent(IRCEvent.Type.TWITCH_DIRECTCHEER)
544     .permissionsRequired(Permissions.ignore)
545     .channelPolicy(omniscientChannelPolicy)
546     .chainable(true)
547 )
548 void onSomeAction(SeenPlugin plugin, const ref IRCEvent event)
549 {
550     /+
551         Updates the user's timestamp to the current time, both sender and target.
552 
553         This will be automatically called on any and all the kinds of
554         [dialect.defs.IRCEvent.Type|IRCEvent.Type]s it is annotated with.
555         Furthermore, it will only trigger if it took place in a home channel.
556 
557         There's no need to check for whether the sender/target is us, as
558         [updateUser] will do it more thoroughly (by stripping any extra modesigns).
559 
560         Don't count non-chatty events if the settings say to ignore them.
561      +/
562 
563     bool skipTarget;
564 
565     with (IRCEvent.Type)
566     switch (event.type)
567     {
568     case CHAN:
569     case QUERY:
570     case EMOTE:
571         // Chatty event. Drop down
572         break;
573 
574     version(TwitchSupport)
575     {
576         case TWITCH_SUB:
577         case TWITCH_CHEER:
578         case TWITCH_SUBUPGRADE:
579         case TWITCH_CHARITY:
580         case TWITCH_BITSBADGETIER:
581         case TWITCH_RITUAL:
582         case TWITCH_EXTENDSUB:
583         case TWITCH_RAID:
584         case TWITCH_CROWDCHANT:
585         case TWITCH_ANNOUNCEMENT:
586         case TWITCH_DIRECTCHEER:
587             // Consider these as chatty events too
588             break;
589 
590         case TWITCH_SUBGIFT:
591         case TWITCH_REWARDGIFT:
592         case TWITCH_GIFTCHAIN:
593         case TWITCH_BULKGIFT:
594         case TWITCH_GIFTRECEIVED:
595         case TWITCH_PAYFORWARD:
596             // These carry targets that should not be counted as having showed activity
597             skipTarget = true;
598             break;
599 
600         case JOIN:
601         case PART:
602             // Ignore Twitch JOINs and PARTs
603             if (plugin.state.server.daemon == IRCServer.Daemon.twitch) return;
604             goto default;
605     }
606 
607     //case MODE:
608     default:
609         if (plugin.seenSettings.ignoreNonChatEvents) return;
610         // Drop down
611         break;
612     }
613 
614     // Only count either the sender or the target, never both at the same time
615     // This to stop it from updating seen time when someone is the target of e.g. a sub gift
616     if (event.sender.nickname)
617     {
618         updateUser(plugin, event.sender.nickname, event.time);
619     }
620     else if (!skipTarget && event.target.nickname)
621     {
622         updateUser(plugin, event.target.nickname, event.time);
623     }
624 }
625 
626 
627 // onQuit
628 /++
629     When someone quits, update their entry with the current timestamp iff they
630     already have an entry.
631 
632     [dialect.defs.IRCEvent.Type.QUIT|QUIT] events don't carry a channel.
633     Users bleed into the seen users database from guest channels by quitting
634     unless we somehow limit it to only accept quits from those in homes. Users
635     in home channels should always have an entry, provided that
636     [dialect.defs.IRCEvent.Type.RPL_NAMREPLY|RPL_NAMREPLY] lists were given when
637     joining one, which seems to (largely?) be the case.
638 
639     Do nothing if an entry was not found.
640  +/
641 @(IRCEventHandler()
642     .onEvent(IRCEvent.Type.QUIT)
643 )
644 void onQuit(SeenPlugin plugin, const ref IRCEvent event)
645 {
646     if (auto seenTimestamp = event.sender.nickname in plugin.seenUsers)
647     {
648         *seenTimestamp = event.time;
649     }
650 }
651 
652 
653 // onNick
654 /++
655     When someone changes nickname, add a new entry with the current timestamp for
656     the new nickname, and remove the old one.
657 
658     Bookkeeping; this is to avoid getting ghost entries in the seen array.
659  +/
660 @(IRCEventHandler()
661     .onEvent(IRCEvent.Type.NICK)
662     .permissionsRequired(Permissions.ignore)
663     .chainable(true)
664 )
665 void onNick(SeenPlugin plugin, const ref IRCEvent event)
666 {
667     if (auto seenTimestamp = event.sender.nickname in plugin.seenUsers)
668     {
669         *seenTimestamp = event.time;
670         //plugin.seenUsers.remove(event.sender.nickname);
671     }
672 }
673 
674 
675 // onWHOReply
676 /++
677     Catches each user listed in a WHO reply and updates their entries in the
678     seen users list, creating them if they don't exist.
679 
680     A WHO request enumerates all members in a channel. It returns several
681     replies, one event per each user in the channel. The
682     [kameloso.plugins.services.chanqueries.ChanQueriesService|ChanQueriesService] services
683     instigates this shortly after having joined one, as a service to other plugins.
684  +/
685 @(IRCEventHandler()
686     .onEvent(IRCEvent.Type.RPL_WHOREPLY)
687     .channelPolicy(omniscientChannelPolicy)
688 )
689 void onWHOReply(SeenPlugin plugin, const ref IRCEvent event)
690 {
691     // Update the user's entry
692     updateUser(plugin, event.target.nickname, event.time);
693 }
694 
695 
696 // onNamesReply
697 /++
698     Catch a NAMES reply and record each person as having been seen.
699 
700     When requesting NAMES on a channel, or when joining one, the server will send
701     a big list of every participant in it, in a big string of nicknames separated by spaces.
702     This is done automatically when you join a channel. Nicknames are prefixed
703     with mode signs if they are operators, voiced or similar, so we'll need to
704     strip that away.
705 
706     More concretely, it uses a [std.algorithm.iteration.splitter|splitter] to iterate each
707     name and call [updateUser] to update (or create) their entry in the
708     [SeenPlugin.seenUsers|seenUsers] associative array.
709  +/
710 @(IRCEventHandler()
711     .onEvent(IRCEvent.Type.RPL_NAMREPLY)
712     .channelPolicy(omniscientChannelPolicy)
713 )
714 void onNamesReply(SeenPlugin plugin, const ref IRCEvent event)
715 {
716     import std.algorithm.iteration : splitter;
717 
718     // Don't trust NAMES on Twitch.
719     if (plugin.state.server.daemon == IRCServer.Daemon.twitch) return;
720 
721     foreach (immutable entry; event.content.splitter(' '))
722     {
723         import dialect.common : stripModesign;
724         import lu.string : nom;
725         import std.typecons : Flag, No, Yes;
726 
727         string slice = entry;  // mutable
728         slice = slice.nom!(Yes.inherit)('!'); // In case SpotChat-like, full nick!ident@address form
729         updateUser(plugin, slice, event.time);
730     }
731 }
732 
733 
734 // onCommandSeen
735 /++
736     Whenever someone says "!seen" in a [dialect.defs.IRCEvent.Type.CHAN|CHAN] or
737     a [dialect.defs.IRCEvent.Type.QUERY|QUERY], and if
738     [dialect.defs.IRCEvent.Type.CHAN|CHAN] then only if in a *home*, this function triggers.
739 
740     The [kameloso.plugins.common.core.IRCEventHandler.Command.word|IRCEventHandler.Command.word]
741     annotation defines a piece of text that the incoming message must start with
742     for this function to be called.
743     [kameloso.plugins.common.core.IRCEventHandler.Command.policy|IRCEventHandler.Command.policy]
744     deals with whether the message has to start with the name of the *bot* or not,
745     and to what extent.
746 
747     Prefix policies can be one of:
748 
749     * [kameloso.plugins.common.core.PrefixPolicy.direct|PrefixPolicy.direct],
750         where the raw command is expected without any message prefix at all;
751         the command is simply that string: "`seen`".
752 
753     * [kameloso.plugins.common.core.PrefixPolicy.prefixed|PrefixPolicy.prefixed],
754         where the message has to start with the command *prefix* character
755         or string (usually `!` or `.`): "`!seen`".
756 
757     * [kameloso.plugins.common.core.PrefixPolicy.nickname|PrefixPolicy.nickname],
758         where the message has to start with bot's nickname:
759         "`kameloso: seen`" -- except if it's in a [dialect.defs.IRCEvent.Type.QUERY|QUERY] message.
760 
761     The plugin system will have made certain we only get messages starting with
762     "`seen`", since we annotated this function with such a
763     [kameloso.plugins.common.core.IRCEventHandler.Command|IRCEventHandler.Command].
764     It will since have been sliced off, so we're left only with the "arguments"
765     to "`seen`". [dialect.defs.IRCEvent.aux|IRCEvent.aux[$-1]] contains the triggering
766     word, if it's needed.
767 
768     If this is a [dialect.defs.IRCEvent.Type.CHAN|CHAN] event, the original lines
769     could (for example) have been "`kameloso: seen Joe`", or merely "`!seen Joe`"
770     (assuming a "`!`" prefix). If it was a private [dialect.defs.IRCEvent.Type.QUERY|QUERY]
771     message, the `kameloso:` prefix will have been removed. In either case, we're
772     left with only the parts we're interested in, and the rest sliced off.
773 
774     As a result, the [dialect.defs.IRCEvent|IRCEvent] `event` would look something
775     like this (given a user `foo` querying "`!seen Joe`" or "`kameloso: seen Joe`"):
776 
777     ---
778     event.type = IRCEvent.Type.CHAN;
779     event.sender.nickname = "foo";
780     event.sender.ident = "~bar";
781     event.sender.address = "baz.foo.bar.org";
782     event.channel = "#bar";
783     event.content = "Joe";
784     event.aux[$-1] = "seen";
785     ---
786 
787     Lastly, the
788     [kameloso.plugins.common.core.IRCEventHandler.Command.description|IRCEventHandler.Command.description]
789     annotation merely defines how this function will be listed in the "online help"
790     list, shown by triggering the [kameloso.plugins.help.HelpPlugin|HelpPlugin]'s'
791     "`help`" command.
792  +/
793 @(IRCEventHandler()
794     .onEvent(IRCEvent.Type.CHAN)
795     .onEvent(IRCEvent.Type.QUERY)
796     .permissionsRequired(Permissions.anyone)
797     .channelPolicy(omniscientChannelPolicy)
798     .addCommand(
799         IRCEventHandler.Command()
800             .word("seen")
801             .policy(PrefixPolicy.prefixed)
802             .description("Queries the bot when it last saw a specified nickname online.")
803             .addSyntax("$command [nickname]")
804     )
805     .addCommand(
806         IRCEventHandler.Command()
807             .word("lastseen")
808             .policy(PrefixPolicy.prefixed)
809             .hidden(true)
810     )
811 )
812 void onCommandSeen(SeenPlugin plugin, const ref IRCEvent event)
813 {
814     import kameloso.time : timeSince;
815     import dialect.common : isValidNickname;
816     import lu.string : beginsWith;
817     import std.algorithm.searching : canFind;
818     import std.datetime.systime : SysTime;
819     import std.format : format;
820 
821     /+
822         The bot uses concurrency messages to queue strings to be sent to the
823         server. This has benefits such as that even a multi-threaded program
824         will have synchronous messages sent, and it's overall an easy and
825         convenient way for plugin to send messages up the stack.
826 
827         There are shorthand versions for sending these messages in
828         [kameloso.messaging], and additionally this module has mixed in
829         `MessagingProxy` in the [SeenPlugin], creating even shorter shorthand
830         versions.
831 
832         You can therefore use them as such:
833 
834         ---
835         with (plugin)  // <-- necessary for the short-shorthand
836         {
837             chan("#d", "Hello world!");
838             query("kameloso", "Hello you!");
839             privmsg(event.channel, event.sender.nickname, "Query or chan!");
840             join("#flerrp");
841             part("#flerrp");
842             topic("#flerrp", "This is a new topic");
843         }
844         ---
845 
846         `privmsg` will either send a channel message or a personal query message
847         depending on the arguments passed to it. If the first `channel` argument
848         is not empty, it will be a `chan` channel message, else a private
849         `query` message.
850      +/
851 
852     immutable requestedUser = event.content.beginsWith('@') ?
853         event.content[1..$] :
854         event.content;
855 
856     with (plugin)
857     {
858         if (!requestedUser.length)
859         {
860             enum pattern = "Usage: <b>%s%s<b> [nickname]";
861             immutable message = pattern.format(plugin.state.settings.prefix, event.aux[$-1]);
862             return privmsg(event.channel, event.sender.nickname, message);
863         }
864         else if (!requestedUser.isValidNickname(plugin.state.server))
865         {
866             // Nickname contained a space or other invalid character
867             immutable message = "Invalid user: <h>" ~ requestedUser ~ "<h>";
868             return privmsg(event.channel, event.sender.nickname, message);
869         }
870         else if (requestedUser == state.client.nickname)
871         {
872             // The requested nick is the bot's.
873             enum message = "T-that's me though...";
874             return privmsg(event.channel, event.sender.nickname, message);
875         }
876         else if (requestedUser == event.sender.nickname)
877         {
878             // The person is asking for seen information about him-/herself.
879             enum message = "That's you!";
880             return privmsg(event.channel, event.sender.nickname, message);
881         }
882 
883         foreach (const channel; state.channels)
884         {
885             if (requestedUser in channel.users)
886             {
887                 immutable pattern = (event.channel.length && (event.channel == channel.name)) ?
888                     "<h>%s<h> is here right now!" :
889                     "<h>%s<h> is online right now.";
890                 immutable message = pattern.format(requestedUser);
891                 return privmsg(event.channel, event.sender.nickname, message);
892             }
893         }
894 
895         // No matches
896 
897         if (const userTimestamp = requestedUser in seenUsers)
898         {
899             enum pattern =  "I last saw <h>%s<h> %s ago.";
900 
901             immutable timestamp = SysTime.fromUnixTime(*userTimestamp);
902             immutable diff = (Clock.currTime - timestamp);
903             immutable elapsed = timeSince!(7, 2)(diff);
904             immutable message = pattern.format(requestedUser, elapsed);
905             privmsg(event.channel, event.sender.nickname, message);
906         }
907         else
908         {
909             // No matches for nickname `event.content` in `plugin.seenUsers`.
910 
911             enum pattern = "I have never seen <h>%s<h>.";
912             immutable message = pattern.format(requestedUser);
913             privmsg(event.channel, event.sender.nickname, message);
914         }
915     }
916 }
917 
918 
919 // updateUser
920 /++
921     Update a given nickname's entry in the seen array with the passed time,
922     expressed in UNIX time.
923 
924     This is not annotated as an IRC event handler and will merely be invoked from
925     elsewhere, like any normal function.
926 
927     Example:
928     ---
929     string potentiallySignedNickname = "@kameloso";
930     long now = Clock.currTime.toUnixTime;
931     updateUser(plugin, potentiallySignedNickname, now);
932     ---
933 
934     Params:
935         plugin = Current [SeenPlugin].
936         signed = Nickname to update, potentially prefixed with one or more modesigns
937             (`@`, `+`, `%`, ...).
938         time = UNIX timestamp of when the user was seen.
939         skipModesignStrp = Whether or not to explicitly not strip modesigns from the nickname.
940  +/
941 void updateUser(
942     SeenPlugin plugin,
943     const string signed,
944     const long time,
945     const Flag!"skipModesignStrip" skipModesignStrip = No.skipModesignStrip)
946 in (signed.length, "Tried to update a user with an empty (signed) nickname")
947 {
948     import dialect.common : stripModesign;
949 
950     // Make sure to strip the modesign, so `@foo` is the same person as `foo`.
951     immutable nickname = skipModesignStrip ? signed : signed.stripModesign(plugin.state.server);
952     if (nickname == plugin.state.client.nickname) return;
953 
954     if (auto nicknameSeen = nickname in plugin.seenUsers)
955     {
956         // User exists in seenUsers; merely update the time
957         *nicknameSeen = time;
958     }
959     else
960     {
961         // New user; add an entry and bump the added counter
962         plugin.seenUsers[nickname] = time;
963     }
964 }
965 
966 
967 // updateAllObservedUsers
968 /++
969     Update all currently observed users.
970 
971     This allows us to update users that don't otherwise trigger events that
972     would register activity, such as silent participants.
973 
974     Params:
975         plugin = Current [SeenPlugin].
976  +/
977 void updateAllObservedUsers(SeenPlugin plugin)
978 {
979     bool[string] uniqueUsers;
980 
981     foreach (immutable channelName, const channel; plugin.state.channels)
982     {
983         foreach (const nickname; channel.users.byKey)
984         {
985             uniqueUsers[nickname] = true;
986         }
987     }
988 
989     immutable now = Clock.currTime.toUnixTime;
990 
991     foreach (immutable nickname; uniqueUsers.byKey)
992     {
993         updateUser(plugin, nickname, now, Yes.skipModesignStrip);
994     }
995 }
996 
997 
998 // loadSeen
999 /++
1000     Given a filename, read the contents and load it into a `long[string]`
1001     associative array, then returns it. If there was no file there to read,
1002     return an empty array for a fresh start.
1003 
1004     Params:
1005         filename = Filename of the file to read from.
1006 
1007     Returns:
1008         `long[string]` associative array; UNIX timestamp longs keyed by nickname strings.
1009  +/
1010 auto loadSeen(const string filename)
1011 {
1012     import kameloso.string : doublyBackslashed;
1013     import std.file : exists, isFile, readText;
1014     import std.json : JSONException, parseJSON;
1015 
1016     long[string] aa;
1017 
1018     if (!filename.exists || !filename.isFile)
1019     {
1020         enum pattern = "<l>%s</> does not exist or is not a file";
1021         logger.warningf(pattern, filename.doublyBackslashed);
1022         return aa;
1023     }
1024 
1025     try
1026     {
1027         const asJSON = parseJSON(filename.readText);
1028 
1029         // Manually insert each entry from the JSON file into the long[string] AA.
1030         foreach (immutable user, const timeJSON; asJSON.object)
1031         {
1032             aa[user] = timeJSON.integer;
1033         }
1034     }
1035     catch (JSONException e)
1036     {
1037         enum pattern = "Could not load seen JSON from file: <l>%s";
1038         logger.errorf(pattern, e.msg);
1039         version(PrintStacktraces) logger.trace(e.info);
1040     }
1041 
1042     // No need to rehash the AA; RehashingAA will do it on assignment
1043     return aa;  //.rehash();
1044 }
1045 
1046 
1047 // saveSeen
1048 /++
1049     Save the passed seen users associative array to disk, in JSON format.
1050 
1051     This is a convenient way to serialise the array.
1052 
1053     Params:
1054         plugin = The current [SeenPlugin].
1055  +/
1056 void saveSeen(SeenPlugin plugin)
1057 {
1058     import std.json : JSONValue;
1059     import std.stdio : File;
1060 
1061     if (!plugin.seenUsers.length) return;
1062 
1063     auto file = File(plugin.seenFile, "w");
1064     file.writeln(JSONValue(plugin.seenUsers.aaOf).toPrettyString);
1065     //file.flush();
1066 }
1067 
1068 
1069 // onWelcome
1070 /++
1071     After we have registered on the server and seen the welcome messages, load
1072     our seen users from file. Additionally set up a Fiber that periodically
1073     saves seen users to disk once every [SeenPlugin.timeBetweenSaves|timeBetweenSaves]
1074     seconds.
1075 
1076     This is to make sure that as little data as possible is lost in the event
1077     of an unexpected shutdown while still not hammering the disk.
1078  +/
1079 @(IRCEventHandler()
1080     .onEvent(IRCEvent.Type.RPL_WELCOME)
1081 )
1082 void onWelcome(SeenPlugin plugin)
1083 {
1084     import kameloso.plugins.common.delayawait : await, delay;
1085     import kameloso.constants : BufferSize;
1086     import core.thread : Fiber;
1087 
1088     plugin.reload();
1089 
1090     void saveDg()
1091     {
1092         while (true)
1093         {
1094             updateAllObservedUsers(plugin);
1095             saveSeen(plugin);
1096             delay(plugin, plugin.timeBetweenSaves, Yes.yield);
1097         }
1098     }
1099 
1100     Fiber saveFiber = new Fiber(&saveDg, BufferSize.fiberStack);
1101     delay(plugin, saveFiber, plugin.timeBetweenSaves);
1102 
1103     // Use an awaiting delegate to report seen users, to avoid it being repeated
1104     // on subsequent manual MOTD calls, unlikely as they may be. For correctness' sake.
1105 
1106     static immutable IRCEvent.Type[2] endOfMotdEventTypes =
1107     [
1108         IRCEvent.Type.RPL_ENDOFMOTD,
1109         IRCEvent.Type.ERR_NOMOTD,
1110     ];
1111 
1112     void endOfMotdDg(IRCEvent)
1113     {
1114         import kameloso.plugins.common.delayawait : unawait;
1115 
1116         unawait(plugin, &endOfMotdDg, endOfMotdEventTypes[]);
1117 
1118         // Reports statistics on how many users are registered as having been seen
1119 
1120         enum pattern = "Currently <i>%d</> users seen.";
1121         logger.logf(pattern, plugin.seenUsers.length);
1122     }
1123 
1124     await(plugin, &endOfMotdDg, endOfMotdEventTypes[]);
1125 }
1126 
1127 
1128 // reload
1129 /++
1130     Reloads seen users from disk.
1131  +/
1132 void reload(SeenPlugin plugin)
1133 {
1134     plugin.seenUsers = loadSeen(plugin.seenFile);
1135 }
1136 
1137 
1138 // teardown
1139 /++
1140     When closing the program or when crashing with grace, save the seen users
1141     array to disk for later reloading.
1142  +/
1143 void teardown(SeenPlugin plugin)
1144 {
1145     updateAllObservedUsers(plugin);
1146     saveSeen(plugin);
1147 }
1148 
1149 
1150 // initResources
1151 /++
1152     Read and write the file of seen people to disk, ensuring that it's there.
1153  +/
1154 void initResources(SeenPlugin plugin)
1155 {
1156     import lu.json : JSONStorage;
1157     import std.json : JSONException;
1158 
1159     JSONStorage json;
1160 
1161     try
1162     {
1163         json.load(plugin.seenFile);
1164     }
1165     catch (JSONException e)
1166     {
1167         import kameloso.plugins.common.misc : IRCPluginInitialisationException;
1168 
1169         version(PrintStacktraces) logger.trace(e);
1170         throw new IRCPluginInitialisationException(
1171             "Seen file is malformed",
1172             plugin.name,
1173             plugin.seenFile,
1174             __FILE__,
1175             __LINE__);
1176     }
1177 
1178     // Let other Exceptions pass up the stack.
1179 
1180     version(Callgrind) {}
1181     else
1182     {
1183         json.save(plugin.seenFile);
1184     }
1185 }
1186 
1187 
1188 import kameloso.thread : Sendable;
1189 
1190 /+
1191     Only some plugins benefit from this one implementning `onBusMessage`, so omit
1192     it if they aren't available.
1193 
1194     Use an enum instead of a version, since for some reason this suddenly broke
1195     on pre-2.093 compilers.
1196  +/
1197 version(WithPipelinePlugin)
1198 {
1199     //version = ShouldImplementOnBusMessage;
1200     enum shouldImplementOnBusMessage = true;
1201 }
1202 else version(WithAdminPlugin)
1203 {
1204     //version = ShouldImplementOnBusMessage;
1205     enum shouldImplementOnBusMessage = true;
1206 }
1207 else
1208 {
1209     enum shouldImplementOnBusMessage = false;
1210 }
1211 
1212 // onBusMessage
1213 /++
1214     Receive a passed [kameloso.thread.Boxed|Boxed] instance with the "`seen`" header,
1215     and calls functions based on the payload message.
1216 
1217     This is used in the Pipeline plugin, to allow us to trigger seen verbs via
1218     the command-line pipe, as well as in the Admin plugin for remote control
1219     over IRC.
1220 
1221     Params:
1222         plugin = The current [SeenPlugin].
1223         header = String header describing the passed content payload.
1224         content = Boxed message content.
1225  +/
1226 debug
1227 version(Posix)
1228 //version(ShouldImplementOnBusMessage)
1229 static if (shouldImplementOnBusMessage)
1230 void onBusMessage(SeenPlugin plugin, const string header, shared Sendable content)
1231 {
1232     if (!plugin.isEnabled) return;
1233     if (header != "seen") return;
1234 
1235     import kameloso.thread : Boxed;
1236     import lu.string : strippedRight;
1237 
1238     auto message = cast(Boxed!string)content;
1239     assert(message, "Incorrectly cast message: " ~ typeof(message).stringof);
1240 
1241     immutable verb = message.payload.strippedRight;
1242 
1243     switch (verb)
1244     {
1245     case "reload":
1246         return .reload(plugin);
1247 
1248     case "save":
1249         updateAllObservedUsers(plugin);
1250         saveSeen(plugin);
1251         logger.info("Seen users saved to disk.");
1252         break;
1253 
1254     default:
1255         logger.error("[seen] Unimplemented bus message verb: <i>", verb);
1256         break;
1257     }
1258 }
1259 
1260 
1261 /++
1262     This full plugin is ~200 source lines of code. (`dscanner --sloc seen.d`)
1263     Even at those numbers it is fairly feature-rich.
1264  +/