1 /++ 2 The Notes plugin allows for storing notes to offline users, to be replayed 3 when they next join the channel. 4 5 If a note is left in a channel, it is stored as a note under that channel 6 and will be played back when the user joins (or optionally shows activity) there. 7 If a note is left in a private message, it is stored as outside of a channel 8 and will be played back in a private query, depending on the same triggers 9 as those of channel notes. 10 11 Activity in one channel will not play back notes left for another channel, 12 but anything will trigger private message playback. 13 14 See_Also: 15 https://github.com/zorael/kameloso/wiki/Current-plugins#notes, 16 [kameloso.plugins.common.core], 17 [kameloso.plugins.common.misc] 18 19 Copyright: [JR](https://github.com/zorael) 20 License: [Boost Software License 1.0](https://www.boost.org/users/license.html) 21 22 Authors: 23 [JR](https://github.com/zorael) 24 +/ 25 module kameloso.plugins.notes; 26 27 version(WithNotesPlugin): 28 29 private: 30 31 import kameloso.plugins; 32 import kameloso.plugins.common.core; 33 import kameloso.plugins.common.awareness : MinimalAuthentication; 34 import kameloso.common : logger; 35 import kameloso.messaging; 36 import dialect.defs; 37 import std.typecons : Flag, No, Yes; 38 39 version(WithChanQueriesService) {} 40 else 41 { 42 pragma(msg, "Warning: The `Notes` plugin will work but not well without the `ChanQueries` service."); 43 } 44 45 mixin MinimalAuthentication; 46 mixin PluginRegistration!NotesPlugin; 47 48 49 // NotesSettings 50 /++ 51 Notes plugin settings. 52 +/ 53 @Settings struct NotesSettings 54 { 55 /++ 56 Toggles whether or not the plugin should react to events at all. 57 +/ 58 @Enabler bool enabled = true; 59 60 /++ 61 Toggles whether or not notes get played back on activity, and not just 62 on [dialect.defs.IRCEvent.Type.JOIN|JOIN]s and 63 [dialect.defs.IRCEvent.Type.ACCOUNT|ACCOUNT]s. 64 65 Ignored on Twitch servers. 66 +/ 67 bool playBackOnAnyActivity = true; 68 } 69 70 71 // Note 72 /++ 73 Embodies the notion of a note, left for an offline user. 74 +/ 75 struct Note 76 { 77 private: 78 import std.json : JSONValue; 79 80 public: 81 /++ 82 Line of text left as a note, optionally Base64-encoded. 83 +/ 84 string line; 85 86 /++ 87 String name of the sender, optionally Base64-encoded. May be a display name. 88 +/ 89 string sender; 90 91 /++ 92 UNIX timestamp of when the note was left. 93 +/ 94 long timestamp; 95 96 /++ 97 Encrypts the note, Base64-encoding [line] and [sender]. 98 +/ 99 void encrypt() 100 { 101 import lu.string : encode64; 102 line = encode64(line); 103 sender = encode64(sender); 104 } 105 106 /++ 107 Decrypts the note, Base64-decoding [line] and [sender]. 108 +/ 109 void decrypt() 110 { 111 import lu.string : decode64; 112 line = decode64(line); 113 sender = decode64(sender); 114 } 115 116 /++ 117 Converts this [Note] into a JSON representation. 118 119 Returns: 120 A [std.json.JSONValue|JSONValue] that describes this [Note]. 121 +/ 122 auto toJSON() const 123 { 124 JSONValue json; 125 json["line"] = JSONValue(this.line); 126 json["sender"] = JSONValue(this.sender); 127 json["timestamp"] = JSONValue(this.timestamp); 128 return json; 129 } 130 131 /++ 132 Creates a [Note] from a JSON representation. 133 134 Params: 135 json = [std.json.JSONValue|JSONValue] to build a [Note] from. 136 +/ 137 static auto fromJSON(const JSONValue json) 138 { 139 Note note; 140 note.line = json["line"].str; 141 note.sender = json["sender"].str; 142 note.timestamp = json["timestamp"].integer; 143 return note; 144 } 145 } 146 147 148 // onJoinOrAccount 149 /++ 150 Plays back notes upon someone joining or upon someone authenticating with services. 151 +/ 152 @(IRCEventHandler() 153 .onEvent(IRCEvent.Type.JOIN) 154 .onEvent(IRCEvent.Type.ACCOUNT) 155 .permissionsRequired(Permissions.anyone) 156 .channelPolicy(ChannelPolicy.home) 157 ) 158 void onJoinOrAccount(NotesPlugin plugin, const ref IRCEvent event) 159 { 160 version(TwitchSupport) 161 { 162 if (plugin.state.server.daemon == IRCServer.Daemon.twitch) 163 { 164 // We can't really rely on JOINs on Twitch and ACCOUNTs don't happen 165 return; 166 } 167 } 168 169 playbackNotes(plugin, event); 170 } 171 172 173 // onChannelMessage 174 /++ 175 Plays back notes upon someone saying something in the channel, provided 176 [NotesSettings.playBackOnAnyActivity] is set. 177 +/ 178 @(IRCEventHandler() 179 .onEvent(IRCEvent.Type.CHAN) 180 .onEvent(IRCEvent.Type.EMOTE) 181 .onEvent(IRCEvent.Type.QUERY) 182 .permissionsRequired(Permissions.anyone) 183 .channelPolicy(ChannelPolicy.home) 184 .chainable(true) 185 ) 186 void onChannelMessage(NotesPlugin plugin, const ref IRCEvent event) 187 { 188 if (plugin.notesSettings.playBackOnAnyActivity || 189 (plugin.state.server.daemon == IRCServer.Daemon.twitch)) 190 { 191 playbackNotes(plugin, event); 192 } 193 } 194 195 196 // onTwitchChannelEvent 197 /++ 198 Plays back notes upon someone performing a Twitch-specific action. 199 +/ 200 version(TwitchSupport) 201 @(IRCEventHandler() 202 .onEvent(IRCEvent.Type.TWITCH_SUB) 203 .onEvent(IRCEvent.Type.TWITCH_SUBGIFT) 204 .onEvent(IRCEvent.Type.TWITCH_CHEER) 205 .onEvent(IRCEvent.Type.TWITCH_REWARDGIFT) 206 .onEvent(IRCEvent.Type.TWITCH_GIFTCHAIN) 207 .onEvent(IRCEvent.Type.TWITCH_BULKGIFT) 208 .onEvent(IRCEvent.Type.TWITCH_SUBUPGRADE) 209 .onEvent(IRCEvent.Type.TWITCH_CHARITY) 210 .onEvent(IRCEvent.Type.TWITCH_BITSBADGETIER) 211 .onEvent(IRCEvent.Type.TWITCH_RITUAL) 212 .onEvent(IRCEvent.Type.TWITCH_EXTENDSUB) 213 .onEvent(IRCEvent.Type.TWITCH_GIFTRECEIVED) 214 .onEvent(IRCEvent.Type.TWITCH_PAYFORWARD) 215 .onEvent(IRCEvent.Type.TWITCH_RAID) 216 .onEvent(IRCEvent.Type.TWITCH_CROWDCHANT) 217 .onEvent(IRCEvent.Type.TWITCH_ANNOUNCEMENT) 218 .onEvent(IRCEvent.Type.TWITCH_DIRECTCHEER) 219 .permissionsRequired(Permissions.ignore) 220 .channelPolicy(ChannelPolicy.home) 221 .chainable(true) 222 ) 223 void onTwitchChannelEvent(NotesPlugin plugin, const ref IRCEvent event) 224 { 225 // No need to check whether we're on Twitch 226 playbackNotes(plugin, event); 227 } 228 229 230 // onWhoReply 231 /++ 232 Plays back notes upon replies of a WHO query. 233 234 These carry a sender, so it's possible we know the account without lookups. 235 236 Do nothing if 237 [kameloso.pods.CoreSettings.eagerLookups|CoreSettings.eagerLookups] is true, 238 as we'd collide with ChanQueries' queries. 239 240 Passes `Yes.background` to [playbackNotes] to ensure it does low-priority 241 background WHOIS queries. 242 +/ 243 @(IRCEventHandler() 244 .onEvent(IRCEvent.Type.RPL_WHOREPLY) 245 .channelPolicy(ChannelPolicy.home) 246 ) 247 void onWhoReply(NotesPlugin plugin, const ref IRCEvent event) 248 { 249 if (plugin.state.settings.eagerLookups) return; 250 251 playbackNotes(plugin, event, Yes.background); 252 } 253 254 255 // playbackNotes 256 /++ 257 Plays back notes. The target is assumed to be the sender of the 258 [dialect.defs.IRCEvent|IRCEvent] passed. 259 260 If the [dialect.defs.IRCEvent|IRCEvent] contains a channel, then playback 261 of both channel and private message notes will be performed. If the channel 262 member is empty, only private message ones. 263 264 Params: 265 plugin = The current [NotesPlugin]. 266 event = The triggering [dialect.defs.IRCEvent|IRCEvent]. 267 background = Whether or not to issue WHOIS queries as low-priority background messages. 268 +/ 269 void playbackNotes( 270 NotesPlugin plugin, 271 const /*ref*/ IRCEvent event, 272 const Flag!"background" background = No.background) 273 { 274 const user = event.sender.nickname.length ? 275 event.sender : 276 event.target; // on RPL_WHOREPLY 277 278 if (!user.nickname.length) 279 { 280 // Despite everything we don't have a user. Bad annotations on calling event handler? 281 return; 282 } 283 284 if (event.channel.length) 285 { 286 import std.range : only; 287 288 // Try both channel and private message notes 289 foreach (immutable wouldBeChannel; only(event.channel, string.init)) 290 { 291 playbackNotesImpl(plugin, wouldBeChannel, user, background); 292 } 293 } 294 else 295 { 296 // Only private message relevant 297 playbackNotesImpl(plugin, string.init, user, background); 298 } 299 } 300 301 302 // playbackNotesImpl 303 /++ 304 Plays back notes. Implementation function. 305 306 Params: 307 plugin = The current [NotesPlugin]. 308 channelName = The name of the channel in which the playback is to take place, 309 or an empty string if it's supposed to take place in a private message. 310 user = [dialect.defs.IRCUser|IRCUser] to replay notes for. 311 background = Whether or not to issue WHOIS queries as low-priority background messages. 312 +/ 313 void playbackNotesImpl( 314 NotesPlugin plugin, 315 const string channelName, 316 const IRCUser user, 317 const Flag!"background" background) 318 { 319 import kameloso.plugins.common.mixins : WHOISFiberDelegate; 320 import std.format : format; 321 322 auto channelNotes = channelName in plugin.notes; 323 if (!channelNotes) return; 324 325 void onSuccess(const IRCUser user) 326 { 327 import std.range : only; 328 329 foreach (immutable id; only(user.nickname, user.account)) 330 { 331 import kameloso.plugins.common.misc : nameOf; 332 import kameloso.time : timeSince; 333 import std.datetime.systime : Clock, SysTime; 334 335 auto notes = id in *channelNotes; 336 if (!notes || !notes.length) continue; 337 338 immutable maybeDisplayName = nameOf(user); 339 immutable nowInUnix = Clock.currTime; 340 341 if (notes.length == 1) 342 { 343 auto note = (*notes)[0]; // mutable 344 immutable timestampAsSysTime = SysTime.fromUnixTime(note.timestamp); 345 immutable duration = (nowInUnix - timestampAsSysTime).timeSince!(7, 1)(No.abbreviate); 346 347 note.decrypt(); 348 enum pattern = "<h>%s<h>! <h>%s<h> left note <b>%s<b> ago: %s"; 349 immutable message = pattern.format(maybeDisplayName, note.sender, duration, note.line); 350 privmsg(plugin.state, channelName, user.nickname, message); 351 } 352 else /*if (notes.length > 1)*/ 353 { 354 enum pattern = "<h>%s<h>! You have <b>%d<b> notes."; 355 immutable message = pattern.format(maybeDisplayName, notes.length); 356 privmsg(plugin.state, channelName, user.nickname, message); 357 358 foreach (/*const*/ note; *notes) 359 { 360 immutable timestampAsSysTime = SysTime.fromUnixTime(note.timestamp); 361 immutable duration = (nowInUnix - timestampAsSysTime).timeSince!(7, 1)(Yes.abbreviate); 362 363 note.decrypt(); 364 enum entryPattern = "<h>%s<h> %s ago: %s"; 365 immutable report = entryPattern.format(note.sender, duration, note.line); 366 privmsg(plugin.state, channelName, user.nickname, report); 367 } 368 } 369 370 (*channelNotes).remove(id); 371 if (!channelNotes.length) plugin.notes.remove(channelName); 372 373 // Don't run the loop twice if the nickname and the account is the same 374 if (user.nickname == user.account) break; 375 } 376 377 saveNotes(plugin); 378 } 379 380 void onFailure(const IRCUser user) 381 { 382 // Merely failed to resolve an account, proceed with success branch 383 return onSuccess(user); 384 } 385 386 if (user.account.length) 387 { 388 return onSuccess(user); 389 } 390 391 mixin WHOISFiberDelegate!(onSuccess, onFailure, Yes.alwaysLookup); 392 393 enqueueAndWHOIS(user.nickname, Yes.issueWhois, background); 394 } 395 396 397 // onCommandAddNote 398 /++ 399 Adds a note to the in-memory storage, and saves it to disk. 400 401 Messages sent in a channel will become messages for the target user in that 402 channel. Those sent in a private query will be private notes, sent privately 403 in the same fashion as channel notes are sent publicly. 404 +/ 405 @(IRCEventHandler() 406 .onEvent(IRCEvent.Type.CHAN) 407 .onEvent(IRCEvent.Type.QUERY) 408 .permissionsRequired(Permissions.anyone) 409 .channelPolicy(ChannelPolicy.home) 410 .addCommand( 411 IRCEventHandler.Command() 412 .word("note") 413 .policy(PrefixPolicy.prefixed) 414 .description("Adds a note to send to an offline person when they come online, " ~ 415 "or when they show activity if already online.") 416 .addSyntax("$command [nickname] [note text]") 417 ) 418 ) 419 void onCommandAddNote(NotesPlugin plugin, const ref IRCEvent event) 420 { 421 import kameloso.plugins.common.misc : nameOf; 422 import lu.string : SplitResults, beginsWith, splitInto, stripped; 423 import std.datetime.systime : Clock; 424 425 void sendUsage() 426 { 427 import std.format : format; 428 429 enum pattern = "Usage: <b>%s%s<b> [nickname] [note text]"; 430 immutable message = pattern.format(plugin.state.settings.prefix, event.aux[$-1]); 431 privmsg(plugin.state, event.channel, event.sender.nickname, message); 432 } 433 434 void sendNoBotMessages() 435 { 436 enum message = "You cannot leave me a message; it would never be replayed."; 437 privmsg(plugin.state, event.channel, event.sender.nickname, message); 438 439 } 440 441 string slice = event.content.stripped; // mutable 442 string target; // mutable 443 444 immutable results = slice.splitInto(target); 445 if (target.beginsWith('@')) target = target[1..$]; 446 447 if ((results != SplitResults.overrun) || !target.length) return sendUsage(); 448 if (target == plugin.state.client.nickname) return sendNoBotMessages(); 449 450 Note note; 451 note.sender = nameOf(event.sender); 452 note.timestamp = Clock.currTime.toUnixTime; 453 note.line = slice; 454 note.encrypt(); 455 456 plugin.notes[event.channel][target] ~= note; 457 saveNotes(plugin); 458 459 enum message = "Note saved."; 460 privmsg(plugin.state, event.channel, event.sender.nickname, message); 461 } 462 463 464 // onWelcome 465 /++ 466 Initialises the Notes plugin. Loads the notes from disk. 467 +/ 468 @(IRCEventHandler() 469 .onEvent(IRCEvent.Type.RPL_WELCOME) 470 ) 471 void onWelcome(NotesPlugin plugin) 472 { 473 plugin.reload(); 474 } 475 476 477 // saveNotes 478 /++ 479 Saves notes to disk, to the [NotesPlugin.notesFile] JSON file. 480 +/ 481 void saveNotes(NotesPlugin plugin) 482 { 483 import lu.json : JSONStorage; 484 import std.json : JSONType; 485 486 JSONStorage json; 487 488 foreach (immutable channelName, channelNotes; plugin.notes) 489 { 490 json[channelName] = null; 491 json[channelName].object = null; 492 493 foreach (immutable nickname, notes; channelNotes) 494 { 495 json[channelName][nickname] = null; 496 json[channelName][nickname].array = null; 497 498 foreach (note; notes) 499 { 500 json[channelName][nickname].array ~= note.toJSON(); 501 } 502 } 503 } 504 505 if (json.type == JSONType.null_) json.object = null; // reset to type object if null_ 506 json.save(plugin.notesFile); 507 } 508 509 510 // loadNotes 511 /++ 512 Loads notes from disk into [NotesPlugin.notes]. 513 +/ 514 void loadNotes(NotesPlugin plugin) 515 { 516 import lu.json : JSONStorage; 517 518 JSONStorage json; 519 json.load(plugin.notesFile); 520 plugin.notes.clear(); 521 522 foreach (immutable channelName, channelNotesJSON; json.object) 523 { 524 foreach (immutable nickname, notesJSON; channelNotesJSON.object) 525 { 526 foreach (noteJSON; notesJSON.array) 527 { 528 plugin.notes[channelName][nickname] ~= Note.fromJSON(noteJSON); 529 } 530 } 531 532 plugin.notes[channelName].rehash(); 533 } 534 535 plugin.notes.rehash(); 536 } 537 538 539 // reload 540 /++ 541 Reloads notes from disk. 542 +/ 543 void reload(NotesPlugin plugin) 544 { 545 return loadNotes(plugin); 546 } 547 548 549 // initResources 550 /++ 551 Ensures that there is a notes file, creating one if there isn't. 552 +/ 553 void initResources(NotesPlugin plugin) 554 { 555 import lu.json : JSONStorage; 556 import std.json : JSONException; 557 558 JSONStorage json; 559 560 try 561 { 562 json.load(plugin.notesFile); 563 } 564 catch (JSONException e) 565 { 566 import kameloso.plugins.common.misc : IRCPluginInitialisationException; 567 568 version(PrintStacktraces) logger.trace(e); 569 throw new IRCPluginInitialisationException( 570 "Notes file is malformed", 571 plugin.name, 572 plugin.notesFile, 573 __FILE__, 574 __LINE__); 575 } 576 577 // Let other Exceptions pass. 578 579 json.save(plugin.notesFile); 580 } 581 582 583 public: 584 585 586 // NotesPlugin 587 /++ 588 The Notes plugin, which allows people to leave messages to each other, 589 for offline communication and such. 590 +/ 591 final class NotesPlugin : IRCPlugin 592 { 593 private: 594 import lu.json : JSONStorage; 595 596 // notesSettings 597 /++ 598 All Notes plugin settings gathered. 599 +/ 600 NotesSettings notesSettings; 601 602 // notes 603 /++ 604 The in-memory JSON storage of all stored notes. 605 606 It is in the JSON form of `Note[][string][string]`, where the first 607 string key is a channel and the second a nickname. 608 +/ 609 Note[][string][string] notes; 610 611 // notesFile 612 /++ 613 Filename of file to save the notes to. 614 +/ 615 @Resource string notesFile = "notes.json"; 616 617 mixin IRCPluginImpl; 618 }