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 }