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 +/