1 /++
2     A simple plugin for querying the time in different timezones.
3 
4     See_Also:
5         https://github.com/zorael/kameloso/wiki/Current-plugins#time,
6         [kameloso.plugins.common.core],
7         [kameloso.plugins.common.misc]
8 
9     Copyright: [JR](https://github.com/zorael)
10     License: [Boost Software License 1.0](https://www.boost.org/users/license.html)
11 
12     Authors:
13         [JR](https://github.com/zorael)
14  +/
15 module kameloso.plugins.time;
16 
17 version(WithTimePlugin):
18 
19 private:
20 
21 import kameloso.plugins;
22 import kameloso.plugins.common.core;
23 import kameloso.plugins.common.awareness : UserAwareness;
24 import kameloso.common : logger;
25 import kameloso.messaging;
26 import dialect.defs;
27 
28 
29 // TimeSettings
30 /++
31     All [TimePlugin] runtime settings, aggregated in a struct.
32  +/
33 @Settings struct TimeSettings
34 {
35     /++
36         Toggle whether or not this plugin should do anything at all.
37      +/
38     @Enabler bool enabled = true;
39 
40     /++
41         Whether to use AM/PM notation instead of 24-hour time.
42      +/
43     bool amPM = false;
44 }
45 
46 
47 // zonestringAliases
48 /++
49     Timezone string aliases.
50 
51     Module-level since we can't have static immutable associative arrays, and as
52     such populated in a module constructor.
53 
54     The alternative is to put it in [TimePlugin] and have a modul-level `setup`
55     that populates it, but since it never changes during the program's run time,
56     it may as well be here.
57  +/
58 immutable string[string] zonestringAliases;
59 
60 
61 // installedTimezones
62 /++
63     String array of installed timezone names.
64 
65     The reasoning around [zonestringAliases] apply here as well.
66  +/
67 immutable string[] installedTimezones;
68 
69 
70 // module ctor
71 /++
72     Populates [zonestringAliases] and [installedTimezones].
73  +/
74 shared static this()
75 {
76     version(Posix)
77     {
78         import std.datetime.timezone : PosixTimeZone;
79 
80         installedTimezones = PosixTimeZone.getInstalledTZNames().idup;
81 
82         zonestringAliases =
83         [
84             "CST" : "US/Central",
85             "EST" : "US/Eastern",
86             "PST" : "US/Pacific",
87             "Central" : "US/Central",
88             "Eastern" : "US/Eastern",
89             "Pacific" : "US/Pacific",
90         ];
91     }
92     else version(Windows)
93     {
94         import std.datetime.timezone : WindowsTimeZone;
95 
96         installedTimezones = WindowsTimeZone.getInstalledTZNames().idup;
97 
98         /+
99         Some excerpts:
100         [
101             "Central America Standard Time",
102             "Central Asia Standard Time",
103             "Central Europe Standard Time",
104             "Central European Standard Time",
105             "Central Pacific Standard Time",
106             "Central Standard Time",
107             "Central Standard Time (Mexico)",
108             "E. Africa Standard Time",
109             "E. Australia Standard Time",
110             "E. Europe Standard Time",
111             "E. South America Standard Time",
112             "Eastern Standard Time",
113             "Eastern Standard Time (Mexico)",
114             "GMT Standard Time",
115             "Greenwich Standard Time",
116             "Middle East Standard Time",
117             "Mountain Standard Time",
118             "Mountain Standard Time (Mexico)",
119             "North Asia East Standard Time",
120             "North Asia Standard Time",
121             "Pacific SA Standard Time",
122             "Pacific Standard Time",
123             "Pacific Standard Time (Mexico)",
124             "SA Eastern Standard Time",
125             "SA Pacific Standard Time",
126             "SA Western Standard Time",
127             "SE Asia Standard Time",
128             "US Eastern Standard Time",
129             "US Mountain Standard Time",
130             "UTC",
131             "UTC+12",
132             "UTC+13",
133             "UTC-02",
134             "UTC-08",
135             "UTC-09",
136             "UTC-11",
137             "W. Australia Standard Time",
138             "W. Central Africa Standard Time",
139             "W. Europe Standard Time",
140             "W. Mongolia Standard Time",
141             "West Asia Standard Time",
142             "West Pacific Standard Time",
143         ]
144          +/
145 
146         zonestringAliases =
147         [
148             "CST" : "Central Standard Time",
149             "EST" : "Eastern Standard Time",
150             "PST" : "Pacific Standard Time",
151             "CET" : "Central European Standard Time",
152         ];
153     }
154     else
155     {
156         static assert(0, "Unsupported platform, please file a bug.");
157     }
158 }
159 
160 
161 // onCommandTime
162 /++
163     Reports the time in the specified timezone, in an override specified in the
164     timezones definitions file, or in the one local to the bot.
165  +/
166 @(IRCEventHandler()
167     .onEvent(IRCEvent.Type.CHAN)
168     .permissionsRequired(Permissions.anyone)
169     .channelPolicy(ChannelPolicy.home)
170     .addCommand(
171         IRCEventHandler.Command()
172             .word("time")
173             .policy(PrefixPolicy.prefixed)
174             .description("Reports the time in a given timezone.")
175             .addSyntax("$command [optional timezone]")
176     )
177 )
178 void onCommandTime(TimePlugin plugin, const ref IRCEvent event)
179 {
180     import lu.string : stripped;
181     import std.datetime.systime : Clock;
182     import std.datetime.timezone : LocalTime;
183     import std.format : format;
184 
185     void sendInvalidTimezone(const string zonestring)
186     {
187         enum pattern = "Invalid timezone: <b>%s<b>";
188         immutable message = pattern.format(zonestring);
189         chan(plugin.state, event.channel, message);
190     }
191 
192     void sendMalformedEntry(const string overrideString)
193     {
194         enum pattern = `Internal error; possible malformed entry "<b>%s<b>" in timezones file.`;
195         immutable message = pattern.format(overrideString);
196         chan(plugin.state, event.channel, message);
197     }
198 
199     void sendInternalError()
200     {
201         enum message = "Internal error.";
202         chan(plugin.state, event.channel, message);
203     }
204 
205     void sendTimestampInZone(const string timestamp, const string specified)
206     {
207         enum pattern = "The time is currently <b>%s<b> in <b>%s<b>.";
208         immutable message = pattern.format(timestamp, specified);
209         chan(plugin.state, event.channel, message);
210     }
211 
212     void sendTimestampLocal(const string timestamp)
213     {
214         enum pattern = "The time is currently <b>%s<b> locally.";
215         immutable message = pattern.format(timestamp);
216         chan(plugin.state, event.channel, message);
217     }
218 
219     version(TwitchSupport)
220     void sendTimestampTwitch(const string timestamp)
221     {
222         import kameloso.plugins.common.misc : nameOf;
223 
224         // No specific timezone specified; report the streamer's
225         // (technically the bot's, unless an override was entered in the config file)
226         enum pattern = "The time is currently %s for %s.";
227         immutable name = (plugin.state.client.nickname == event.channel[1..$]) ?
228             "me" :
229             nameOf(plugin, event.channel[1..$]);
230         immutable message = pattern.format(timestamp, name);
231         chan(plugin.state, event.channel, message);
232     }
233 
234     string getTimestamp(/*const*/ ubyte hour, const ubyte minute)
235     {
236         import std.format : format;
237 
238         if (plugin.timeSettings.amPM)
239         {
240             immutable amPM = (hour < 12) ? "AM" : "PM";
241             hour %= 12;
242             if (hour == 0) hour = 12;
243 
244             enum pattern = "%d:%02d %s";
245             return pattern.format(hour, minute, amPM);
246         }
247         else
248         {
249             enum pattern = "%02d:%02d";
250             return pattern.format(hour, minute);
251         }
252     }
253 
254     immutable specified = event.content.stripped;
255     const overrideZone = event.channel in plugin.channelTimezones;
256 
257     immutable timezone = specified.length ?
258         getTimezoneByName(specified) :
259         overrideZone ?
260             getTimezoneByName(*overrideZone) :
261             LocalTime();
262 
263     if (!timezone)
264     {
265         return specified.length ?
266             sendInvalidTimezone(specified) :
267             overrideZone ?
268                 sendMalformedEntry(*overrideZone) :
269                 sendInternalError();
270     }
271 
272     immutable now = Clock.currTime(timezone);
273     immutable timestamp = getTimestamp(now.hour, now.minute);
274 
275     if (specified.length)
276     {
277         return sendTimestampInZone(timestamp, specified);
278     }
279 
280     version(TwitchSupport)
281     {
282         if (plugin.state.server.daemon == IRCServer.Daemon.twitch)
283         {
284             return sendTimestampTwitch(timestamp);
285         }
286     }
287 
288     return overrideZone ?
289         sendTimestampInZone(timestamp, *overrideZone) :
290         sendTimestampLocal(timestamp);
291 }
292 
293 
294 // getTimezoneByName
295 /++
296     Takes a string representation of a timezone (e.g. `Europe/Stockholm`) and
297     returns a [std.datetime.timezone.TimeZone|TimeZone] that corresponds to it,
298     if one was found.
299 
300     Params:
301         specified = Timezone identification string.
302 
303     Returns:
304         A [std.datetime.timezone.TimeZone|TimeZone] that matches the passed
305         `specified` identification string, or `null` if none was found.
306  +/
307 auto getTimezoneByName(const string specified)
308 in (specified.length, "Tried to get timezone of an empty string")
309 {
310     import core.time : TimeException;
311 
312     string getZonestring()
313     {
314         import lu.string : contains;
315         import std.algorithm.searching : canFind;
316 
317         if (immutable zonestringAlias = specified in zonestringAliases)
318         {
319             return *zonestringAlias;
320         }
321 
322         version(Posix)
323         {
324             import std.array : replace;
325 
326             string resolvePrefixedTimezone(const string zonestring)
327             {
328                 if (zonestring.contains('/')) return string.init;
329 
330                 static immutable string[7] prefixes =
331                 [
332                     "Europe/",
333                     "America/",
334                     "Asia/",
335                     "Africa/",
336                     "Australia/",
337                     "Pacific/",
338                     "Etc/",
339                 ];
340 
341                 foreach (immutable prefix; prefixes[])
342                 {
343                     immutable prefixed = prefix ~ zonestring;
344                     if (installedTimezones.canFind(prefixed)) return prefixed;
345                 }
346 
347                 return string.init;
348             }
349 
350             immutable withUnderscores = specified.replace(' ', '_');
351             return installedTimezones.canFind(withUnderscores) ?
352                 withUnderscores :
353                 resolvePrefixedTimezone(withUnderscores);
354         }
355         else version(Windows)
356         {
357             string resolveStandardTimezone(const string zonestring)
358             {
359                 import std.algorithm.searching : endsWith;
360 
361                 if (zonestring.endsWith("Standard Time")) return string.init;
362 
363                 immutable withStandardTime = zonestring ~ " Standard Time";
364                 return installedTimezones.canFind(withStandardTime) ?
365                     withStandardTime :
366                     string.init;
367             }
368 
369             return installedTimezones.canFind(specified) ?
370                 specified :
371                 resolveStandardTimezone(specified);
372         }
373         else
374         {
375             static assert(0, "Unsupported platform, please file a bug.");
376         }
377     }
378 
379     try
380     {
381         version(Windows)
382         {
383             import std.datetime.timezone : TZ = WindowsTimeZone;
384         }
385         else version(Posix)
386         {
387             import std.datetime.timezone : TZ = PosixTimeZone;
388         }
389         else
390         {
391             static assert(0, "Unsupported platform, please file a bug.");
392         }
393 
394         return TZ.getTimeZone(getZonestring());
395     }
396     catch (TimeException _)
397     {
398         // core.time.TimeException@std/datetime/timezone.d(2096): /usr/share/zoneinfo is not a file.
399         // On invalid timezone string
400         return null;
401     }
402 }
403 
404 ///
405 unittest
406 {
407     import std.exception : assertThrown;
408     import core.time : TimeException;
409 
410     // core.time.TimeException@std/datetime/timezone.d(2096): /usr/share/zoneinfo is not a file.
411     // As above
412 
413     void assertMatches(const string specified, const string expected)
414     {
415         version(Posix)
416         {
417             import std.datetime.timezone : TZ = PosixTimeZone;
418         }
419         else version(Windows)
420         {
421             import std.datetime.timezone : TZ = WindowsTimeZone;
422         }
423 
424         immutable actual = getTimezoneByName(specified);
425         immutable result = TZ.getTimeZone(expected);
426         assert((actual.name == result.name), result.name);
427     }
428 
429     version(Posix)
430     {
431         assertMatches("Stockholm", "Europe/Stockholm");
432         assertMatches("CET", "CET");
433         assertMatches("Tokyo", "Asia/Tokyo");
434         assertThrown!TimeException(assertMatches("Nangijala", string.init));
435     }
436     else version(Windows)
437     {
438         assertMatches("CET", "Central European Standard Time");
439         assertMatches("Central", "Central Standard Time");
440         assertMatches("Tokyo", "Tokyo Standard Time");
441         assertMatches("UTC", "UTC");
442         assertThrown!TimeException(assertMatches("Nangijala", string.init));
443     }
444 }
445 
446 
447 // onCommandSetZone
448 /++
449     Sets the timezone for a channel, to be used to properly pad the output of `!time`.
450  +/
451 @(IRCEventHandler()
452     .onEvent(IRCEvent.Type.CHAN)
453     .permissionsRequired(Permissions.operator)
454     .channelPolicy(ChannelPolicy.home)
455     .addCommand(
456         IRCEventHandler.Command()
457             .word("setzone")
458             .policy(PrefixPolicy.prefixed)
459             .description("Sets the timezone to be used when querying the time in a channel.")
460             .addSyntax("$command [timezone string]")
461     )
462 )
463 void onCommandSetZone(TimePlugin plugin, const ref IRCEvent event)
464 {
465     import lu.string : stripped;
466     import std.format : format;
467     import std.json : JSONValue;
468 
469     immutable specified = event.content.stripped;
470 
471     if (specified == "-")
472     {
473         plugin.channelTimezones.remove(event.channel);
474         saveResourceToDisk(plugin.channelTimezones, plugin.timezonesFile);
475 
476         enum message = "Timezone cleared.";
477         return chan(plugin.state, event.channel, message);
478     }
479 
480     immutable timezone = getTimezoneByName(specified);
481 
482     if (!timezone || !timezone.name.length)
483     {
484         enum pattern = "Invalid timezone: <b>%s<b>";
485         immutable message = pattern.format(specified);
486         return chan(plugin.state, event.channel, message);
487     }
488 
489     plugin.channelTimezones[event.channel] = timezone.name;
490     saveResourceToDisk(plugin.channelTimezones, plugin.timezonesFile);
491 
492     enum pattern = "Timezone changed to <b>%s<b>.";
493     immutable message = pattern.format(timezone.name);
494     chan(plugin.state, event.channel, message);
495 }
496 
497 
498 // saveResourceToDisk
499 /++
500     Saves the timezone map to disk in JSON format.
501 
502     Params:
503         aa = The JSON-convertible resource to save.
504         filename = Filename of the file to write to.
505  +/
506 void saveResourceToDisk(const string[string] aa, const string filename)
507 in (filename.length, "Tried to save resources to an empty filename string")
508 {
509     import std.json : JSONValue;
510     import std.stdio : File;
511 
512     File(filename, "w").writeln(JSONValue(aa).toPrettyString);
513 }
514 
515 
516 // reload
517 /++
518     Reloads the timezones map from disk.
519  +/
520 void reload(TimePlugin plugin)
521 {
522     import lu.json : JSONStorage, populateFromJSON;
523     import std.typecons : Flag, No, Yes;
524 
525     JSONStorage channelTimezonesJSON;
526     channelTimezonesJSON.load(plugin.timezonesFile);
527     plugin.channelTimezones.clear();
528     plugin.channelTimezones.populateFromJSON(channelTimezonesJSON, Yes.lowercaseKeys);
529 }
530 
531 
532 // initResources
533 /++
534     Reads and writes the file of timezones to disk, ensuring that they're there and
535     properly formatted.
536  +/
537 void initResources(TimePlugin plugin)
538 {
539     import lu.json : JSONStorage;
540     import std.json : JSONException;
541 
542     JSONStorage timezonesJSON;
543 
544     try
545     {
546         timezonesJSON.load(plugin.timezonesFile);
547     }
548     catch (JSONException e)
549     {
550         import kameloso.plugins.common.misc : IRCPluginInitialisationException;
551 
552         version(PrintStacktraces) logger.trace(e);
553         throw new IRCPluginInitialisationException(
554             "Timezones file is malformed",
555             plugin.name,
556             plugin.timezonesFile,
557             __FILE__,
558             __LINE__);
559     }
560 
561     // Let other Exceptions pass.
562 
563     timezonesJSON.save(plugin.timezonesFile);
564 }
565 
566 
567 mixin UserAwareness;
568 mixin PluginRegistration!TimePlugin;
569 
570 version(TwitchSupport)
571 {
572     import kameloso.plugins.common.awareness : ChannelAwareness, TwitchAwareness;
573 
574     mixin ChannelAwareness;  // Only needed to get TwitchAwareness in
575     mixin TwitchAwareness;
576 }
577 
578 public:
579 
580 
581 // TimePlugin
582 /++
583     The Time plugin replies to queries of what the time is in a given timezone.
584  +/
585 final class TimePlugin : IRCPlugin
586 {
587 private:
588     import lu.json : JSONStorage;
589 
590     // timeSettings
591     /++
592         All Time plugin settings gathered.
593      +/
594     TimeSettings timeSettings;
595 
596     // channelTimezones
597     /++
598         Channel timezone map.
599      +/
600     string[string] channelTimezones;
601 
602     // timezonesFile
603     /++
604         Filename of file to which we should save timezone channel definitions.
605      +/
606     @Resource string timezonesFile = "timezones.json";
607 
608     mixin IRCPluginImpl;
609 }