1 /++ 2 Functionality related to configuration; verifying it, correcting it, 3 reading it from/writing it to disk, and parsing it from command-line arguments. 4 5 Employs the standard [std.getopt] to read arguments from the command line 6 to construct and populate instances of the structs needed for the bot to 7 function, like [dialect.defs.IRCClient|IRCClient], [dialect.defs.IRCServer|IRCServer] 8 and [kameloso.pods.IRCBot|IRCBot]. 9 10 See_Also: 11 [kameloso.kameloso], 12 [kameloso.main], 13 [kameloso.common] 14 15 Copyright: [JR](https://github.com/zorael) 16 License: [Boost Software License 1.0](https://www.boost.org/users/license.html) 17 18 Authors: 19 [JR](https://github.com/zorael) 20 +/ 21 module kameloso.config; 22 23 private: 24 25 import kameloso.kameloso : Kameloso; 26 import kameloso.common : logger; 27 import kameloso.pods : IRCBot; 28 import dialect.defs : IRCClient, IRCServer; 29 import lu.common : Next; 30 import std.getopt : GetoptResult; 31 import std.stdio : stdout; 32 import std.typecons : Flag, No, Yes; 33 34 @safe: 35 36 37 // printHelp 38 /++ 39 Prints the [std.getopt.getopt|getopt] "helpWanted" help table to screen. 40 41 Example: 42 --- 43 auto results = args.getopt( 44 "n|nickname", "Bot nickname", &nickname, 45 "s|server", "Server", &server, 46 // ... 47 ); 48 49 if (results.helpWanted) 50 { 51 printHelp(results); 52 } 53 --- 54 55 Params: 56 results = Results from a [std.getopt.getopt|getopt] call. 57 +/ 58 void printHelp(GetoptResult results) 59 { 60 import std.array : Appender; 61 import std.getopt : Option; 62 import std.stdio : writeln; 63 64 // Copied from std.getopt 65 static void customGetoptFormatter(Sink) 66 (auto ref Sink sink, 67 const Option[] opt, 68 const string pattern /*= "%*s %*s%*s%s\n"*/) 69 { 70 import std.algorithm.comparison : min, max; 71 import std.format : formattedWrite; 72 73 size_t ls, ll; 74 75 foreach (it; opt) 76 { 77 ls = max(ls, it.optShort.length); 78 ll = max(ll, it.optLong.length); 79 } 80 81 foreach (it; opt) 82 { 83 sink.formattedWrite(pattern, ls, it.optShort, ll, it.optLong, it.help); 84 } 85 } 86 87 enum pattern = "%*s %*s %s\n"; 88 89 Appender!(char[]) sink; 90 sink.reserve(4096); // ~2398 91 92 sink.put('\n'); 93 customGetoptFormatter(sink, results.options, pattern); 94 sink.put("\nA dash (-) clears, so -C- translates to no channels, -A- to no account name, etc.\n"); 95 96 writeln(sink.data); 97 } 98 99 100 // verboselyWriteConfig 101 /++ 102 Writes configuration to file, verbosely. 103 104 Params: 105 instance = Reference to the current [kameloso.kameloso.Kameloso|Kameloso]. 106 client = Reference to the current [dialect.defs.IRCClient|IRCClient]. 107 server = Reference to the current [dialect.defs.IRCServer|IRCServer]. 108 bot = Reference to the current [kameloso.pods.IRCBot|IRCBot]. 109 giveInstructions = Whether or not to give instructions to edit the 110 generated file and supply admins and/or home channels. 111 +/ 112 void verboselyWriteConfig( 113 ref Kameloso instance, 114 ref IRCClient client, 115 ref IRCServer server, 116 ref IRCBot bot, 117 const Flag!"giveInstructions" giveInstructions = Yes.giveInstructions) @system 118 { 119 import kameloso.common : logger, printVersionInfo; 120 import kameloso.printing : printObjects; 121 import std.file : exists; 122 123 // --save was passed; write configuration to file and quit 124 125 if (!instance.settings.headless) 126 { 127 import std.stdio : writeln; 128 printVersionInfo(); 129 writeln(); 130 if (instance.settings.flush) stdout.flush(); 131 } 132 133 // If we don't initialise the plugins there'll be no plugins array 134 instance.initPlugins(); 135 136 immutable shouldGiveBrightTerminalHint = 137 !instance.settings.monochrome && 138 !instance.settings.brightTerminal && 139 !instance.settings.configFile.exists; 140 141 writeConfigurationFile(instance, instance.settings.configFile); 142 143 if (!instance.settings.headless) 144 { 145 import kameloso.string : doublyBackslashed; 146 147 printObjects(client, instance.bot, server, instance.connSettings, instance.settings); 148 enum pattern = "Configuration written to <i>%s"; 149 logger.logf(pattern, instance.settings.configFile.doublyBackslashed); 150 151 if (!instance.bot.admins.length && !instance.bot.homeChannels.length && giveInstructions) 152 { 153 logger.trace(); 154 logger.log("Edit it and make sure it contains at least one of the following:"); 155 giveConfigurationMinimalInstructions(); 156 } 157 158 if (shouldGiveBrightTerminalHint) 159 { 160 logger.trace(); 161 giveBrightTerminalHint(Yes.alsoAboutConfigSetting); 162 } 163 } 164 } 165 166 167 // printSettings 168 /++ 169 Prints the core settings and all plugins' settings to screen. 170 171 Params: 172 instance = Reference to the current [kameloso.kameloso.Kameloso|Kameloso]. 173 +/ 174 void printSettings(ref Kameloso instance) @system 175 { 176 import kameloso.common : printVersionInfo; 177 import kameloso.printing : printObjects; 178 import std.stdio : writeln; 179 180 printVersionInfo(); 181 writeln(); 182 183 printObjects!(No.all) 184 (instance.parser.client, 185 instance.bot, 186 instance.parser.server, 187 instance.connSettings, 188 instance.settings); 189 190 instance.initPlugins(); 191 192 foreach (plugin; instance.plugins) plugin.printSettings(); 193 194 if (instance.settings.flush) stdout.flush(); 195 } 196 197 198 // manageConfigFile 199 /++ 200 Writes and/or edits the configuration file. Broken out into a separate 201 function to lower the size of [handleGetopt]. 202 203 Params: 204 instance = The current [kameloso.kameloso.Kameloso|Kameloso] instance. 205 shouldWriteConfig = Writing to the configuration file was explicitly 206 requested or implicitly by changing some setting via getopt. 207 shouldOpenTerminalEditor = Opening the configuration file in a 208 terminal text editor was requested. 209 shouldOpenGraphicalEditor = Opening the configuration file in a 210 graphical text editor was requested. 211 force = (Windows) If set, uses `explorer.exe` as the graphical editor, 212 otherwise uses `notepad.exe`. 213 +/ 214 void manageConfigFile( 215 ref Kameloso instance, 216 const Flag!"shouldWriteConfig" shouldWriteConfig, 217 const Flag!"shouldOpenTerminalEditor" shouldOpenTerminalEditor, 218 const Flag!"shouldOpenGraphicalEditor" shouldOpenGraphicalEditor, 219 const Flag!"force" force) @system 220 { 221 import kameloso.string : doublyBackslashed; 222 import std.file : exists; 223 224 /++ 225 Opens up the configuration file in a terminal text editor. 226 +/ 227 void openTerminalEditor() 228 { 229 import std.process : environment, spawnProcess, wait; 230 231 // Let exceptions (ProcessExceptions) fall through and get caught 232 // by [kameloso.main.tryGetopt]. 233 234 immutable editor = environment.get("EDITOR", string.init); 235 236 if (!editor.length) 237 { 238 version(Windows) 239 { 240 enum message = "Missing <l>%EDITOR%</> environment variable; cannot guess editor."; 241 } 242 else version(Posix) 243 { 244 enum message = "Missing <l>$EDITOR</> environment variable; cannot guess editor."; 245 } 246 else 247 { 248 static assert(0, "Unsupported platform, please file a bug."); 249 } 250 251 return logger.error(message); 252 } 253 254 enum pattern = "Attempting to open <i>%s</> with <i>%s</>..."; 255 logger.logf(pattern, instance.settings.configFile.doublyBackslashed, editor.doublyBackslashed); 256 257 immutable command = [ editor, instance.settings.configFile ]; 258 spawnProcess(command).wait; 259 } 260 261 /++ 262 Opens up the configuration file in a graphical text editor. 263 +/ 264 void openGraphicalEditor() 265 { 266 import std.process : execute; 267 268 version(OSX) 269 { 270 enum editor = "open"; 271 } 272 else version(Posix) 273 { 274 import std.process : environment; 275 276 // Assume XDG 277 enum editor = "xdg-open"; 278 279 immutable isGraphicalEnvironment = 280 instance.settings.force || 281 environment.get("DISPLAY", string.init).length || 282 environment.get("WAYLAND_DISPLAY", string.init).length; 283 284 if (!isGraphicalEnvironment) 285 { 286 enum message = "No graphical environment appears to be running; cannot open editor."; 287 return logger.error(message); 288 } 289 } 290 else version(Windows) 291 { 292 immutable editor = force ? "explorer.exe" : "notepad.exe"; 293 } 294 else 295 { 296 static assert(0, "Unsupported platform, please file a bug."); 297 } 298 299 // Let exceptions (ProcessExceptions) fall through and get caught 300 // by [kameloso.main.tryGetopt]. 301 302 enum pattern = "Attempting to open <i>%s</> in a graphical text editor..."; 303 logger.logf(pattern, instance.settings.configFile.doublyBackslashed); 304 305 immutable command = [ editor, instance.settings.configFile ]; 306 execute(command); 307 } 308 309 /+ 310 Write config if... 311 * --save was passed 312 * a setting was changed via getopt (also passes Yes.shouldWriteConfig) 313 * the config file doesn't exist 314 +/ 315 316 immutable configFileExists = instance.settings.configFile.exists; 317 318 if (shouldWriteConfig || !configFileExists) 319 { 320 verboselyWriteConfig( 321 instance, 322 instance.parser.client, 323 instance.parser.server, 324 instance.bot, 325 cast(Flag!"giveInstructions")(!configFileExists)); 326 } 327 328 if (shouldOpenTerminalEditor || shouldOpenGraphicalEditor) 329 { 330 // If instructions were given, add an extra linebreak to make it prettier 331 if (!configFileExists) logger.trace(); 332 333 // --edit or --gedit was passed, so open up an appropriate editor 334 if (shouldOpenTerminalEditor) 335 { 336 openTerminalEditor(); 337 } 338 else /*if (shouldOpenGraphicalEditor)*/ 339 { 340 openGraphicalEditor(); 341 } 342 } 343 } 344 345 346 // writeToDisk 347 /++ 348 Saves the passed configuration text to disk, with the given filename. 349 350 Optionally (and by default) adds the "kameloso" version banner at the head of it. 351 352 Example: 353 --- 354 Appender!(char[]) sink; 355 sink.serialise(client, server, settings); 356 immutable configText = sink.data.justifiedEntryValueText; 357 writeToDisk("kameloso.conf", configText, Yes.addBanner); 358 --- 359 360 Params: 361 filename = Filename of file to write to. 362 configurationText = Content to write to file. 363 banner = Whether or not to add the "kameloso bot" banner at the head of the file. 364 +/ 365 void writeToDisk( 366 const string filename, 367 const string configurationText, 368 const Flag!"addBanner" banner = Yes.addBanner) 369 { 370 import std.file : mkdirRecurse; 371 import std.path : dirName; 372 import std.stdio : File; 373 374 immutable dir = filename.dirName; 375 mkdirRecurse(dir); 376 377 auto file = File(filename, "w"); 378 379 if (banner) 380 { 381 import kameloso.constants : KamelosoInfo; 382 import std.datetime.systime : Clock; 383 import core.time : msecs; 384 385 auto timestamp = Clock.currTime; 386 timestamp.fracSecs = 0.msecs; 387 388 file.writefln("# kameloso v%s configuration file (%s)\n", 389 cast(string)KamelosoInfo.version_, timestamp); 390 } 391 392 file.writeln(configurationText); 393 } 394 395 396 // giveConfigurationMinimalInstructions 397 /++ 398 Displays a hint on how to complete a minimal configuration file. 399 400 It assumes that the bot's [kameloso.pods.IRCBot.admins|IRCBot.admins] and 401 [kameloso.pods.IRCBot.homeChannels|IRCBot.homeChannels] are both empty. 402 (Else it should not have been called.) 403 +/ 404 void giveConfigurationMinimalInstructions() 405 { 406 enum adminPattern = "...one or more <i>admins</> who get administrative control over the bot."; 407 logger.trace(adminPattern); 408 enum homePattern = "...one or more <i>homeChannels</> in which to operate."; 409 logger.trace(homePattern); 410 } 411 412 413 // flatten 414 /++ 415 Flattens a dynamic array by splitting elements containing more than one 416 value (as separated by a separator string) into separate elements. 417 418 Params: 419 separator = Separator, defaults to a space string (" "). 420 arr = A dynamic array. 421 422 Returns: 423 A new array, with any elements previously containing more than one 424 `separator`-separated entries now in separate elements. 425 +/ 426 auto flatten(string separator = " ", T)(const T[] arr) 427 { 428 import lu.semver : LuSemVer; 429 import lu.string : stripped; 430 import std.algorithm.iteration : filter, joiner, map, splitter; 431 import std.array : array; 432 433 auto toReturn = arr 434 .map!(elem => elem.splitter(separator)) 435 .joiner 436 .map!(elem => elem.stripped) 437 .filter!(elem => elem.length) 438 .array; 439 440 static if ( 441 (LuSemVer.majorVersion >= 1) && 442 (LuSemVer.minorVersion >= 2) && 443 (LuSemVer.patchVersion >= 2)) 444 { 445 return toReturn; 446 } 447 else 448 { 449 // FIXME: lu.string.stripped makes the type const 450 // Remove this when we update lu 451 return toReturn.dup; 452 } 453 } 454 455 /// 456 unittest 457 { 458 import std.conv : to; 459 460 { 461 auto arr = [ "a", "b", "c d e ", "f" ]; 462 arr = flatten(arr); 463 assert((arr == [ "a", "b", "c", "d", "e", "f" ]), arr.to!string); 464 } 465 { 466 auto arr = [ "a", "b", "c,d,e,,,", "f" ]; 467 arr = flatten!","(arr); 468 assert((arr == [ "a", "b", "c", "d", "e", "f" ]), arr.to!string); 469 } 470 { 471 auto arr = [ "a", "b", "c dhonk e ", "f" ]; 472 arr = flatten!"honk"(arr); 473 assert((arr == [ "a", "b", "c d", "e", "f" ]), arr.to!string); 474 } 475 { 476 auto arr = [ "a", "b", "c" ]; 477 arr = flatten(arr); 478 assert((arr == [ "a", "b", "c" ]), arr.to!string); 479 } 480 } 481 482 483 public: 484 485 486 // handleGetopt 487 /++ 488 Reads command-line options and applies them over values previously read from 489 the configuration file, as well as dictates some other behaviour. 490 491 The priority of options then becomes getopt over config file over hardcoded defaults. 492 493 Example: 494 --- 495 Kameloso instance; 496 Next next = handleGetopt(instance); 497 498 if (next == Next.returnSuccess) return 0; 499 // ... 500 --- 501 502 Params: 503 instance = Reference to the current [kameloso.kameloso.Kameloso|Kameloso]. 504 505 Returns: 506 [lu.common.Next.continue_|Next.continue_] or 507 [lu.common.Next.returnSuccess|Next.returnSuccess] depending on whether 508 the arguments chosen mean the program should proceed or not. 509 510 Throws: 511 [std.getopt.GetOptException|GetOptException] if an unknown flag is passed. 512 +/ 513 auto handleGetopt(ref Kameloso instance) @system 514 { 515 import kameloso.common : printVersionInfo; 516 import kameloso.configreader : readConfigInto; 517 import std.getopt : arraySep, config, getopt; 518 519 bool shouldWriteConfig; 520 bool shouldOpenTerminalEditor; 521 bool shouldOpenGraphicalEditor; 522 bool shouldShowVersion; 523 bool shouldShowSettings; 524 bool shouldAppendToArrays; 525 bool noop; 526 527 // Windows-only but must be declared regardless of platform 528 bool shouldDownloadOpenSSL; 529 bool shouldDownloadCacert; 530 531 // Likewise but version `TwitchSupport` 532 bool shouldSetupTwitch; 533 534 string[] inputGuestChannels; 535 string[] inputHomeChannels; 536 string[] inputAdmins; 537 538 arraySep = ","; 539 540 /+ 541 Call getopt on args once and look for any specified configuration files 542 so we know what to read. As such it has to be done before the 543 [kameloso.configreader.readConfigInto] call. Then call getopt on the rest. 544 Include "c|config" in the normal getopt to have it automatically 545 included in the --help text. 546 +/ 547 548 // Results can be const 549 auto argsSlice = instance.args[]; 550 const configFileResults = getopt(argsSlice, 551 config.caseSensitive, 552 config.bundling, 553 config.passThrough, 554 "c|config", &instance.settings.configFile, 555 "version", &shouldShowVersion, 556 ); 557 558 if (shouldShowVersion) 559 { 560 // --version was passed; show version info and quit 561 printVersionInfo(No.colours); 562 return Next.returnSuccess; 563 } 564 565 // Ignore invalid/missing entries here, report them when initialising plugins 566 instance.settings.configFile.readConfigInto( 567 instance.parser.client, 568 instance.bot, 569 instance.parser.server, 570 instance.connSettings, 571 instance.settings); 572 573 applyDefaults( 574 instance.parser.client, 575 instance.parser.server, 576 instance.bot); 577 578 import kameloso.terminal : applyMonochromeAndFlushOverrides; 579 580 // Non-TTYs (eg. pagers) can't show colours. 581 // Apply overrides here after having read config file 582 applyMonochromeAndFlushOverrides(instance.settings.monochrome, instance.settings.flush); 583 584 // Get `--monochrome` again; let it overwrite what applyMonochromeAndFlushOverrides 585 // and readConfigInto set it to 586 cast(void)getopt(argsSlice, 587 config.caseSensitive, 588 config.bundling, 589 config.passThrough, 590 "monochrome", &instance.settings.monochrome, 591 "setup-twitch", &shouldSetupTwitch, 592 ); 593 594 /++ 595 Call getopt in a nested function so we can call it both to merely 596 parse for settings and to format the help listing. 597 +/ 598 auto callGetopt(/*const*/ string[] theseArgs, const Flag!"quiet" quiet) 599 { 600 import kameloso.logger : LogLevel; 601 import kameloso.terminal.colours.tags : expandTags; 602 import std.conv : text, to; 603 import std.format : format; 604 import std.path : extension; 605 import std.process : environment; 606 import std.random : uniform; 607 import std.range : repeat; 608 609 immutable setSyntax = quiet ? string.init : 610 "<i>--set plugin</>.<i>setting</>=<i>value</>".expandTags(LogLevel.off); 611 612 immutable nickname = quiet ? string.init : 613 instance.parser.client.nickname.length ? instance.parser.client.nickname : "<random>"; 614 615 immutable sslText = quiet ? string.init : 616 instance.connSettings.ssl ? "true" : 617 instance.settings.force ? "false" : "inferred by port"; 618 619 immutable passwordMask = quiet ? string.init : 620 instance.bot.password.length ? '*'.repeat(uniform(6, 10)).to!string : string.init; 621 622 immutable passMask = quiet ? string.init : 623 instance.bot.pass.length ? '*'.repeat(uniform(6, 10)).to!string : string.init; 624 625 immutable editorCommand = quiet ? string.init : 626 environment.get("EDITOR", string.init); 627 628 immutable editorVariableValue = quiet ? string.init : 629 editorCommand.length ? 630 " [<i>%s</>]".expandTags(LogLevel.trace).format(editorCommand) : 631 string.init; 632 633 string formatNum(const size_t num) 634 { 635 return (quiet || (num == 0)) ? string.init : 636 " (<i>%d</>)".expandTags(LogLevel.trace).format(num); 637 } 638 639 void appendCustomSetting(const string _, const string setting) 640 { 641 instance.customSettings ~= setting; 642 } 643 644 version(Windows) 645 { 646 enum getOpenSSLString = "Download OpenSSL for Windows"; 647 enum getCacertString = "Download a <i>cacert.pem</> certificate " ~ 648 "bundle (implies <i>--save</>)"; 649 } 650 else 651 { 652 enum getOpenSSLString = "(Windows only)"; 653 enum getCacertString = getOpenSSLString; 654 } 655 656 immutable configFileExtension = instance.settings.configFile.extension; 657 immutable defaultGeditProgramString = 658 "[<i>the default application used to open <l>*" ~ 659 configFileExtension ~ "<i> files on your system</>]"; 660 661 version(Windows) 662 { 663 immutable geditProgramString = instance.settings.force ? 664 defaultGeditProgramString : 665 "[<i>notepad.exe</>]"; 666 } 667 else 668 { 669 alias geditProgramString = defaultGeditProgramString; 670 } 671 672 version(TwitchSupport) 673 { 674 enum setupTwitchString = "Set up a basic Twitch connection"; 675 } 676 else 677 { 678 enum setupTwitchString = "(Requires Twitch support)"; 679 } 680 681 return getopt(theseArgs, 682 config.caseSensitive, 683 config.bundling, 684 "n|nickname", 685 quiet ? string.init : 686 "Nickname [<i>%s</>]" 687 .expandTags(LogLevel.trace) 688 .format(nickname), 689 &instance.parser.client.nickname, 690 "s|server", 691 quiet ? string.init : 692 "Server address [<i>%s</>]" 693 .expandTags(LogLevel.trace) 694 .format(instance.parser.server.address), 695 &instance.parser.server.address, 696 "P|port", 697 quiet ? string.init : 698 "Server port [<i>%d</>]" 699 .expandTags(LogLevel.trace) 700 .format(instance.parser.server.port), 701 &instance.parser.server.port, 702 "6|ipv6", 703 quiet ? string.init : 704 "Use IPv6 where available [<i>%s</>]" 705 .expandTags(LogLevel.trace) 706 .format(instance.connSettings.ipv6), 707 &instance.connSettings.ipv6, 708 "ssl", 709 quiet ? string.init : 710 "Attempt SSL connection [<i>%s</>]" 711 .expandTags(LogLevel.trace) 712 .format(sslText), 713 &instance.connSettings.ssl, 714 "A|account", 715 quiet ? string.init : 716 "Services account name" ~ 717 (instance.bot.account.length ? 718 " [<i>%s</>]" 719 .expandTags(LogLevel.trace) 720 .format(instance.bot.account) : 721 string.init), 722 &instance.bot.account, 723 "p|password", 724 quiet ? string.init : 725 "Services account password" ~ 726 (instance.bot.password.length ? 727 " [<i>%s</>]" 728 .expandTags(LogLevel.trace) 729 .format(passwordMask) : 730 string.init), 731 &instance.bot.password, 732 "pass", 733 quiet ? string.init : 734 "Registration pass" ~ 735 (instance.bot.pass.length ? 736 " [<i>%s</>]" 737 .expandTags(LogLevel.trace) 738 .format(passMask) : 739 string.init), 740 &instance.bot.pass, 741 "admins", 742 quiet ? string.init : 743 "Administrators' services accounts, comma-separated" ~ 744 formatNum(instance.bot.admins.length), 745 &inputAdmins, 746 "H|homeChannels", 747 quiet ? string.init : 748 text(("Home channels to operate in, comma-separated " ~ 749 "(escape or enquote any octothorpe <i>#</>s)").expandTags(LogLevel.trace), 750 formatNum(instance.bot.homeChannels.length)), 751 &inputHomeChannels, 752 "C|guestChannels", 753 quiet ? string.init : 754 "Non-home channels to idle in, comma-separated (ditto)" ~ 755 formatNum(instance.bot.guestChannels.length), 756 &inputGuestChannels, 757 "a|append", 758 quiet ? string.init : 759 "Append input home channels, guest channels and " ~ 760 "admins instead of overriding", 761 &shouldAppendToArrays, 762 "settings", 763 quiet ? string.init : 764 "Show all plugins' settings", 765 &shouldShowSettings, 766 "bright", 767 quiet ? string.init : 768 "Adjust colours for bright terminal backgrounds [<i>%s</>]" 769 .expandTags(LogLevel.trace) 770 .format(instance.settings.brightTerminal), 771 &instance.settings.brightTerminal, 772 "monochrome", 773 quiet ? string.init : 774 "Use monochrome output [<i>%s</>]" 775 .expandTags(LogLevel.trace) 776 .format(instance.settings.monochrome), 777 //&settings.monochrome, 778 &noop, 779 "set", 780 quiet ? string.init : 781 text("Manually change a setting (syntax: ", setSyntax, ')'), 782 &appendCustomSetting, 783 "c|config", 784 quiet ? string.init : 785 "Specify a different configuration file [<i>%s</>]" 786 .expandTags(LogLevel.trace) 787 .format(instance.settings.configFile), 788 //&settings.configFile, 789 &noop, 790 "r|resourceDir", 791 quiet ? string.init : 792 "Specify a different resource directory [<i>%s</>]" 793 .expandTags(LogLevel.trace) 794 .format(instance.settings.resourceDirectory), 795 &instance.settings.resourceDirectory, 796 /+"receiveTimeout", 797 quiet ? string.init : 798 ("Socket receive timeout in milliseconds; lower numbers " ~ 799 "improve worse-case responsiveness of outgoing messages [<i>%d</>]") 800 .expandTags(LogLevel.trace) 801 .format(instance.connSettings.receiveTimeout), 802 &instance.connSettings.receiveTimeout, 803 "privateKey", 804 quiet ? string.init : 805 "Path to private key file, used to authenticate some SSL connections", 806 &instance.connSettings.privateKeyFile, 807 "cert", 808 quiet ? string.init : 809 "Path to certificate file, ditto", 810 &instance.connSettings.certFile,+/ 811 "cacert", 812 quiet ? string.init : 813 "Path to <i>cacert.pem</> certificate bundle, or equivalent" 814 .expandTags(LogLevel.trace), 815 &instance.connSettings.caBundleFile, 816 "get-openssl", 817 quiet ? string.init : 818 getOpenSSLString, 819 &shouldDownloadOpenSSL, 820 "get-cacert", 821 quiet ? string.init : 822 getCacertString 823 .expandTags(LogLevel.trace), 824 &shouldDownloadCacert, 825 "setup-twitch", 826 quiet ? string.init : 827 setupTwitchString, 828 //&shouldSetupTwitch, 829 &noop, 830 "numeric", 831 quiet ? string.init : 832 "Use numeric output of addresses", 833 &instance.settings.numericAddresses, 834 "summary", 835 quiet ? string.init : 836 "Show a connection summary on program exit [<i>%s</>]" 837 .expandTags(LogLevel.trace) 838 .format(instance.settings.exitSummary), 839 &instance.settings.exitSummary, 840 "force", 841 quiet ? string.init : 842 "Force connect (skips some checks)", 843 &instance.settings.force, 844 "flush", 845 quiet ? string.init : 846 "Set terminal mode to flush screen output after each line written to it. " ~ 847 "(Use this if the screen only occasionally updates)", 848 &instance.settings.flush, 849 "save", 850 quiet ? string.init : 851 "Write configuration to file", 852 &shouldWriteConfig, 853 "edit", 854 quiet ? string.init : 855 ("Open the configuration file in a *terminal* text editor " ~ 856 "(or the application defined in the <i>$EDITOR</> " ~ 857 "environment variable)").expandTags(LogLevel.trace) ~ editorVariableValue, 858 &shouldOpenTerminalEditor, 859 "gedit", 860 quiet ? string.init : 861 ("Open the configuration file in a *graphical* text editor " ~ geditProgramString) 862 .expandTags(LogLevel.trace), 863 &shouldOpenGraphicalEditor, 864 "headless", 865 quiet ? string.init : 866 "Headless mode, disabling all terminal output", 867 &instance.settings.headless, 868 "version", 869 quiet ? string.init : 870 "Show version information", 871 &shouldShowVersion, 872 ); 873 } 874 875 const backupClient = instance.parser.client; 876 auto backupServer = instance.parser.server; // cannot opEqual const IRCServer with mutable 877 const backupBot = instance.bot; 878 879 version(TwitchSupport) 880 { 881 if (shouldSetupTwitch) 882 { 883 // Do this early to allow for manual overrides with --server etc 884 instance.parser.server.address = "irc.chat.twitch.tv"; 885 instance.parser.server.port = 6697; 886 instance.parser.client.nickname = "doesntmatter"; 887 instance.parser.client.user = "ignored"; 888 instance.parser.client.realName = "likewise"; 889 shouldWriteConfig = true; 890 shouldOpenGraphicalEditor = true; 891 } 892 } 893 894 // No need to catch the return value, only used for --help 895 cast(void)callGetopt(instance.args, Yes.quiet); 896 897 // Save the user from themselves. (A receive timeout of 0 breaks all sorts of things.) 898 if (instance.connSettings.receiveTimeout == 0) 899 { 900 import kameloso.constants : Timeout; 901 instance.connSettings.receiveTimeout = Timeout.receiveMsecs; 902 } 903 904 // Reinitialise the logger with new settings 905 import kameloso.logger : KamelosoLogger; 906 static import kameloso.common; 907 kameloso.common.logger = new KamelosoLogger(instance.settings); 908 909 // Support channels and admins being separated by spaces (mirror config file behaviour) 910 if (inputHomeChannels.length) inputHomeChannels = flatten(inputHomeChannels); 911 if (inputGuestChannels.length) inputGuestChannels = flatten(inputGuestChannels); 912 if (inputAdmins.length) inputAdmins = flatten(inputAdmins); 913 914 // Manually override or append channels, depending on `shouldAppendChannels` 915 if (shouldAppendToArrays) 916 { 917 if (inputHomeChannels.length) instance.bot.homeChannels ~= inputHomeChannels; 918 if (inputGuestChannels.length) instance.bot.guestChannels ~= inputGuestChannels; 919 if (inputAdmins.length) instance.bot.admins ~= inputAdmins; 920 } 921 else 922 { 923 if (inputHomeChannels.length) instance.bot.homeChannels = inputHomeChannels; 924 if (inputGuestChannels.length) instance.bot.guestChannels = inputGuestChannels; 925 if (inputAdmins.length) instance.bot.admins = inputAdmins; 926 } 927 928 /// Strip channel whitespace and make lowercase 929 static void stripAndLower(ref string[] channels) 930 { 931 import lu.string : stripped; 932 import std.algorithm.iteration : map, uniq; 933 import std.algorithm.sorting : sort; 934 import std.array : array; 935 import std.uni : toLower; 936 937 channels = channels 938 .map!(channelName => channelName.stripped.toLower) 939 .array 940 .sort 941 .uniq 942 .array; 943 } 944 945 stripAndLower(instance.bot.homeChannels); 946 stripAndLower(instance.bot.guestChannels); 947 948 // Remove duplicate channels (where a home is also featured as a normal channel) 949 size_t[] duplicates; 950 951 foreach (immutable channelName; instance.bot.homeChannels) 952 { 953 import std.algorithm.searching : countUntil; 954 immutable chanIndex = instance.bot.guestChannels.countUntil(channelName); 955 if (chanIndex != -1) duplicates ~= chanIndex; 956 } 957 958 foreach_reverse (immutable chanIndex; duplicates) 959 { 960 import std.algorithm.mutation : SwapStrategy, remove; 961 instance.bot.guestChannels = instance.bot.guestChannels.remove!(SwapStrategy.unstable)(chanIndex); 962 } 963 964 // Clear entries that are dashes 965 import lu.objmanip : replaceMembers; 966 967 instance.parser.client.replaceMembers("-"); 968 instance.bot.replaceMembers("-"); 969 970 // Handle showstopper arguments (that display something and then exits) 971 972 if (configFileResults.helpWanted) 973 { 974 // --help|-h was passed, show the help table and quit 975 // It's okay to reuse args, it's probably empty save for arg0 976 // and we just want the help listing 977 978 if (!instance.settings.headless) 979 { 980 printVersionInfo(); 981 printHelp(callGetopt(instance.args, No.quiet)); 982 if (instance.settings.flush) stdout.flush(); 983 } 984 985 return Next.returnSuccess; 986 } 987 988 version(Windows) 989 { 990 if (shouldDownloadCacert || shouldDownloadOpenSSL) 991 { 992 import kameloso.ssldownloads : downloadWindowsSSL; 993 994 immutable settingsTouched = downloadWindowsSSL( 995 instance, 996 cast(Flag!"shouldDownloadCacert")shouldDownloadCacert, 997 cast(Flag!"shouldDownloadOpenSSL")shouldDownloadOpenSSL); 998 999 if (*instance.abort) return Next.returnFailure; 1000 1001 if (settingsTouched) 1002 { 1003 import std.stdio : writeln; 1004 shouldWriteConfig = true; 1005 writeln(); 1006 } 1007 else 1008 { 1009 if (!shouldWriteConfig) return Next.returnSuccess; 1010 } 1011 } 1012 } 1013 1014 if (shouldWriteConfig || shouldOpenTerminalEditor || shouldOpenGraphicalEditor) 1015 { 1016 // --save and/or --edit was passed; defer to manageConfigFile 1017 1018 if (instance.settings.headless) 1019 { 1020 // Silently abort if we're in headless mode 1021 return Next.returnFailure; 1022 } 1023 1024 // Also pass Yes.shouldWriteConfig if something was changed via getopt 1025 shouldWriteConfig = 1026 shouldWriteConfig || 1027 instance.customSettings.length || 1028 (instance.parser.client != backupClient) || 1029 (instance.parser.server != backupServer) || 1030 (instance.bot != backupBot); 1031 1032 manageConfigFile( 1033 instance, 1034 cast(Flag!"shouldWriteConfig")shouldWriteConfig, 1035 cast(Flag!"shouldOpenTerminalEditor")shouldOpenTerminalEditor, 1036 cast(Flag!"shouldOpenGraphicalEditor")shouldOpenGraphicalEditor, 1037 cast(Flag!"force")instance.settings.force); 1038 return Next.returnSuccess; 1039 } 1040 1041 if (shouldShowSettings) 1042 { 1043 // --settings was passed, show all options and quit 1044 if (!instance.settings.headless) printSettings(instance); 1045 return Next.returnSuccess; 1046 } 1047 1048 return Next.continue_; 1049 } 1050 1051 1052 // writeConfigurationFile 1053 /++ 1054 Write all settings to the configuration filename passed. 1055 1056 It gathers configuration text from all plugins before formatting it into 1057 nice columns, then writes it all in one go. 1058 1059 Additionally gives some empty settings default values. 1060 1061 Example: 1062 --- 1063 Kameloso instance; 1064 writeConfigurationFile(instance, instance.settings.configFile); 1065 --- 1066 1067 Params: 1068 instance = Reference to the current [kameloso.kameloso.Kameloso|Kameloso], 1069 with all its plugins and settings. 1070 filename = String filename of the file to write to. 1071 +/ 1072 void writeConfigurationFile(ref Kameloso instance, const string filename) @system 1073 { 1074 import kameloso.constants : KamelosoDefaults; 1075 import kameloso.platform : rbd = resourceBaseDirectory; 1076 import lu.serialisation : justifiedEntryValueText, serialise; 1077 import lu.string : beginsWith, encode64; 1078 import std.array : Appender; 1079 import std.path : buildNormalizedPath, expandTilde; 1080 1081 Appender!(char[]) sink; 1082 sink.reserve(4096); // ~2234 1083 1084 // Take the opportunity to set a default quit reason. We can't do this in 1085 // applyDefaults because it's a perfectly valid use-case not to have a quit 1086 // string, and having it there would enforce the default string if none present. 1087 if (!instance.bot.quitReason.length) instance.bot.quitReason = KamelosoDefaults.quitReason; 1088 1089 // Copied from kameloso.main.resolvePaths 1090 version(Windows) 1091 { 1092 import std.string : replace; 1093 immutable escapedServerDirName = instance.parser.server.address.replace(':', '_'); 1094 } 1095 else version(Posix) 1096 { 1097 immutable escapedServerDirName = instance.parser.server.address; 1098 } 1099 else 1100 { 1101 static assert(0, "Unsupported platform, please file a bug."); 1102 } 1103 1104 immutable defaultResourceHomeDir = buildNormalizedPath(rbd, "kameloso"); 1105 immutable settingsResourceDir = instance.settings.resourceDirectory.expandTilde(); 1106 immutable defaultFullServerResourceDir = escapedServerDirName.length ? 1107 buildNormalizedPath( 1108 defaultResourceHomeDir, 1109 "server", 1110 escapedServerDirName) : 1111 string.init; 1112 1113 // Snapshot resource dir in case we change it 1114 immutable resourceDirSnapshot = settingsResourceDir; 1115 1116 if ((settingsResourceDir == defaultResourceHomeDir) || 1117 (settingsResourceDir == defaultFullServerResourceDir)) 1118 { 1119 // If the resource directory is the default (unset), 1120 // or if it is what would be automatically inferred, write it out as empty 1121 instance.settings.resourceDirectory = string.init; 1122 } 1123 1124 if (!instance.settings.force && 1125 instance.bot.password.length && 1126 !instance.bot.password.beginsWith("base64:")) 1127 { 1128 instance.bot.password = "base64:" ~ encode64(instance.bot.password); 1129 } 1130 1131 if (!instance.settings.force && 1132 instance.bot.pass.length && 1133 !instance.bot.pass.beginsWith("base64:")) 1134 { 1135 instance.bot.pass = "base64:" ~ encode64(instance.bot.pass); 1136 } 1137 1138 sink.serialise( 1139 instance.parser.client, 1140 instance.bot, 1141 instance.parser.server, 1142 instance.connSettings, 1143 instance.settings); 1144 sink.put('\n'); 1145 1146 foreach (immutable i, plugin; instance.plugins) 1147 { 1148 immutable addedSomething = plugin.serialiseConfigInto(sink); 1149 1150 if (addedSomething && (i+1 < instance.plugins.length)) 1151 { 1152 sink.put('\n'); 1153 } 1154 } 1155 1156 immutable justified = sink.data.idup.justifiedEntryValueText; 1157 writeToDisk(filename, justified, Yes.addBanner); 1158 1159 // Restore resource dir in case we aren't exiting 1160 instance.settings.resourceDirectory = resourceDirSnapshot; 1161 } 1162 1163 1164 // notifyAboutMissingSettings 1165 /++ 1166 Prints some information about missing configuration entries to the local terminal. 1167 1168 Params: 1169 missingEntries = A `string[][string]` associative array of dynamic 1170 `string[]` arrays, keyed by configuration section name strings. 1171 These arrays contain missing settings. 1172 binaryPath = The program's `args[0]`. 1173 configFile = (Relative) path of the configuration file. 1174 +/ 1175 void notifyAboutMissingSettings(const string[][string] missingEntries, 1176 const string binaryPath, 1177 const string configFile) 1178 { 1179 import kameloso.string : doublyBackslashed; 1180 import std.conv : text; 1181 import std.path : baseName; 1182 1183 logger.log("Your configuration file is missing the following settings:"); 1184 1185 foreach (immutable section, const sectionEntries; missingEntries) 1186 { 1187 enum missingPattern = "...under <l>[<i>%s<l>]</>: %-(<i>%s%|</>, %)"; 1188 logger.tracef(missingPattern, section, sectionEntries); 1189 } 1190 1191 enum pattern = "Use <i>%s --save</> to regenerate the file, " ~ 1192 "updating it with all available configuration. [<i>%s</>]"; 1193 logger.trace(); 1194 logger.tracef(pattern, binaryPath.baseName, configFile.doublyBackslashed); 1195 logger.trace(); 1196 } 1197 1198 1199 // notifyAboutIncompleteConfiguration 1200 /++ 1201 Displays an error if the configuration is *incomplete*, e.g. missing crucial information. 1202 1203 It assumes such information is missing, and that the check has been done at 1204 the calling site. 1205 1206 Params: 1207 configFile = Full path to the configuration file. 1208 binaryPath = Full path to the current binary. 1209 +/ 1210 void notifyAboutIncompleteConfiguration(const string configFile, const string binaryPath) 1211 { 1212 import kameloso.string : doublyBackslashed; 1213 import std.file : exists; 1214 import std.path : baseName; 1215 1216 logger.warning("No administrators nor home channels configured!"); 1217 logger.trace(); 1218 1219 if (configFile.exists) 1220 { 1221 enum pattern = "Edit <i>%s</> and make sure it has at least one of the following:"; 1222 logger.logf(pattern, configFile.doublyBackslashed); 1223 giveConfigurationMinimalInstructions(); 1224 } 1225 else 1226 { 1227 enum pattern = "Use <i>%s --save</> to generate a configuration file."; 1228 logger.logf(pattern, binaryPath.baseName); 1229 } 1230 1231 logger.trace(); 1232 } 1233 1234 1235 // giveBrightTerminalHint 1236 /++ 1237 Display a hint about the existence of the `--bright` getopt flag. 1238 1239 Params: 1240 alsoConfigSetting = Whether or not to also give a hint about the 1241 possibility of saving the setting to 1242 [kameloso.pods.CoreSettings.brightTerminal|CoreSettings.brightTerminal]. 1243 +/ 1244 void giveBrightTerminalHint( 1245 const Flag!"alsoAboutConfigSetting" alsoConfigSetting = No.alsoAboutConfigSetting) 1246 { 1247 enum brightPattern = "If text is difficult to read (eg. white on white), " ~ 1248 "try running the program with <i>--bright</> or <i>--monochrome</>."; 1249 logger.trace(brightPattern); 1250 1251 if (alsoConfigSetting) 1252 { 1253 enum configPattern = "The setting will be made persistent if you pass it " ~ 1254 "at the same time as <i>--save</>."; 1255 logger.trace(configPattern); 1256 } 1257 } 1258 1259 1260 // applyDefaults 1261 /++ 1262 Completes a client's, server's and bot's member fields. Empty members are 1263 given values from compile-time defaults. 1264 1265 Nickname, user, GECOS/"real name", server address and server port are 1266 required. If there is no nickname, generate a random one. For any other empty values, 1267 update them with relevant such from [kameloso.constants.KamelosoDefaults|KamelosoDefaults] 1268 (and [kameloso.constants.KamelosoDefaultIntegers|KamelosoDefaultIntegers]). 1269 1270 Params: 1271 client = Reference to the [dialect.defs.IRCClient|IRCClient] to complete. 1272 server = Reference to the [dialect.defs.IRCServer|IRCServer] to complete. 1273 bot = Reference to the [kameloso.pods.IRCBot|IRCBot] to complete. 1274 +/ 1275 void applyDefaults(ref IRCClient client, ref IRCServer server, ref IRCBot bot) 1276 out (; (client.nickname.length), "Empty client nickname") 1277 out (; (client.user.length), "Empty client username") 1278 out (; (client.realName.length), "Empty client GECOS/real name") 1279 out (; (server.address.length), "Empty server address") 1280 out (; (server.port != 0), "Server port of 0") 1281 out (; (bot.quitReason.length), "Empty bot quit reason") 1282 out (; (bot.partReason.length), "Empty bot part reason") 1283 { 1284 import kameloso.constants : KamelosoDefaults, KamelosoDefaultIntegers; 1285 1286 // If no client.nickname set, generate a random guest name. 1287 if (!client.nickname.length) 1288 { 1289 import std.format : format; 1290 import std.random : uniform; 1291 1292 enum pattern = "guest%03d"; 1293 client.nickname = pattern.format(uniform(0, 1000)); 1294 bot.hasGuestNickname = true; 1295 } 1296 1297 // If no client.user set, inherit from [kameloso.constants.KamelosoDefaults|KamelosoDefaults]. 1298 if (!client.user.length) 1299 { 1300 client.user = KamelosoDefaults.user; 1301 } 1302 1303 // If no client.realName set, inherit. 1304 if (!client.realName.length) 1305 { 1306 client.realName = KamelosoDefaults.realName; 1307 } 1308 1309 // If no server.address set, inherit. 1310 if (!server.address.length) 1311 { 1312 server.address = KamelosoDefaults.serverAddress; 1313 } 1314 1315 // Ditto but [kameloso.constants.KamelosoDefaultIntegers|KamelosoDefaultIntegers]. 1316 if (server.port == 0) 1317 { 1318 server.port = KamelosoDefaultIntegers.port; 1319 } 1320 1321 if (!bot.quitReason.length) 1322 { 1323 bot.quitReason = KamelosoDefaults.quitReason; 1324 } 1325 1326 if (!bot.partReason.length) 1327 { 1328 bot.partReason = KamelosoDefaults.partReason; 1329 } 1330 } 1331 1332 /// 1333 unittest 1334 { 1335 import kameloso.constants : KamelosoDefaults, KamelosoDefaultIntegers; 1336 import std.conv : to; 1337 1338 IRCClient client; 1339 IRCServer server; 1340 IRCBot bot; 1341 1342 assert(!client.nickname.length, client.nickname); 1343 assert(!client.user.length, client.user); 1344 assert(!client.ident.length, client.ident); 1345 assert(!client.realName.length, client.realName); 1346 assert(!server.address, server.address); 1347 assert((server.port == 0), server.port.to!string); 1348 1349 applyDefaults(client, server, bot); 1350 1351 assert(client.nickname.length); 1352 assert((client.user == KamelosoDefaults.user), client.user); 1353 assert(!client.ident.length, client.ident); 1354 assert((client.realName == KamelosoDefaults.realName), client.realName); 1355 assert((server.address == KamelosoDefaults.serverAddress), server.address); 1356 assert((server.port == KamelosoDefaultIntegers.port), server.port.to!string); 1357 assert((bot.quitReason == KamelosoDefaults.quitReason), bot.quitReason); 1358 assert((bot.partReason == KamelosoDefaults.partReason), bot.partReason); 1359 1360 client.nickname = string.init; 1361 applyDefaults(client, server, bot); 1362 1363 assert(client.nickname.length, client.nickname); 1364 }