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 }