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 }