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