1 /++
2     The SedReplace plugin imitates the UNIX `sed` tool, allowing for the
3     replacement/substitution of text. It does not require the tool itself though,
4     and will work on Windows too.
5 
6     $(CONSOLE
7         $ echo "foo bar baz" | sed "s/bar/qux/"
8         foo qux baz
9     )
10 
11     It has no bot commands, as everything is done by scanning messages for signs
12     of `s/this/that/` patterns.
13 
14     It supports a delimiter of `/`, `|`, `#`, `@`, ` `, `_` and `;`, but more
15     can be trivially added. See the [DelimiterCharacters] alias.
16 
17     You can also end it with a `g` to set the global flag, to have more than one
18     match substituted.
19 
20     $(CONSOLE
21         $ echo "foo bar baz" | sed "s/bar/qux/g"
22         $ echo "foo bar baz" | sed "s|bar|qux|g"
23         $ echo "foo bar baz" | sed "s#bar#qux#g"
24         $ echo "foo bar baz" | sed "s@bar@qux@"
25         $ echo "foo bar baz" | sed "s bar qux "
26         $ echo "foo bar baz" | sed "s_bar_qux_"
27         $ echo "foo bar baz" | sed "s;bar;qux"  // only if relaxSyntax is true
28     )
29 
30     See_Also:
31         https://github.com/zorael/kameloso/wiki/Current-plugins#sedreplace,
32         [kameloso.plugins.common.core],
33         [kameloso.plugins.common.misc]
34 
35     Copyright: [JR](https://github.com/zorael)
36     License: [Boost Software License 1.0](https://www.boost.org/users/license.html)
37 
38     Authors:
39         [JR](https://github.com/zorael)
40  +/
41 module kameloso.plugins.sedreplace;
42 
43 version(WithSedReplacePlugin):
44 
45 private:
46 
47 import kameloso.plugins;
48 import kameloso.plugins.common.core;
49 import kameloso.plugins.common.awareness : MinimalAuthentication;
50 import kameloso.messaging;
51 import dialect.defs;
52 import lu.container : CircularBuffer;
53 import lu.string : beginsWith;
54 import std.meta : AliasSeq;
55 import std.typecons : Flag, No, Yes;
56 
57 
58 /++
59     Characters to support as delimiters in the replace expression.
60 
61     More can be added but if any are removed unittests will need to be updated.
62  +/
63 alias DelimiterCharacters = AliasSeq!('/', '|', '#', '@', ' ', '_', ';');
64 
65 
66 // SedReplaceSettings
67 /++
68     All sed-replace plugin settings, gathered in a struct.
69  +/
70 @Settings struct SedReplaceSettings
71 {
72     /// Toggles whether or not the plugin should react to events at all.
73     @Enabler bool enabled = true;
74 
75     /++
76         How many lines back a sed-replacement call may reach. If this is 5, then
77         the last 5 messages will be taken into account and examined for
78         applicability when replacing.
79      +/
80     int history = 5;
81 
82     /++
83         Toggles whether or not replacement expressions have to properly end with
84         the delimiter (`s/abc/ABC/`), or if it may be omitted (`s/abc/ABC`).
85      +/
86     bool relaxSyntax = true;
87 }
88 
89 
90 // Line
91 /++
92     Struct aggregate of a spoken line and the timestamp when it was said.
93  +/
94 struct Line
95 {
96     /// Contents of last line uttered.
97     string content;
98 
99     /// When the last line was spoken, in UNIX time.
100     long timestamp;
101 }
102 
103 
104 // sedReplace
105 /++
106     `sed`-replaces a line with a substitution string.
107 
108     This clones the behaviour of the UNIX-like `echo "foo" | sed 's/foo/bar/'`.
109 
110     Example:
111     ---
112     string line = "This is a line";
113     string expression = "s/s/z/g";
114     assert(line.sedReplace(expression, No.relaxSyntax) == "Thiz iz a line");
115     ---
116 
117     Params:
118         line = Line to apply the `sed`-replace pattern to.
119         expr = Replacement pattern to apply.
120         relaxSyntax = Whether or not to require the expression to end with the delimiter.
121 
122     Returns:
123         Original line with the changes the replace pattern incurred, or an empty
124         `string.init` if nothing was changed.
125  +/
126 auto sedReplace(
127     const string line,
128     const string expr,
129     const Flag!"relaxSyntax" relaxSyntax)
130 in (line.length, "Tried to `sedReplace` an empty line")
131 in ((expr.length >= 5), "Tried to `sedReplace` with an invalid-length expression")
132 in (expr.beginsWith('s'), "Tried to `sedReplace` with a non-expression expression")
133 {
134     immutable delimiter = expr[1];
135 
136     switch (delimiter)
137     {
138     foreach (immutable c; DelimiterCharacters)
139     {
140         case c:
141             return line.sedReplaceImpl!c(expr, relaxSyntax);
142     }
143     default:
144         return line;
145     }
146 }
147 
148 ///
149 unittest
150 {
151     {
152         enum before = "abc 123 def 456";
153         immutable after = before.sedReplace("s/123/789/", No.relaxSyntax);
154         assert((after == "abc 789 def 456"), after);
155     }
156     {
157         enum before = "I am a fish";
158         immutable after = before.sedReplace("s|a|e|g", No.relaxSyntax);
159         assert((after == "I em e fish"), after);
160     }
161     {
162         enum before = "Lorem ipsum dolor sit amet";
163         immutable after = before.sedReplace("s###g", No.relaxSyntax);
164         assert((after == "Lorem ipsum dolor sit amet"), after);
165     }
166     {
167         enum before = "高所恐怖症";
168         immutable after = before.sedReplace("s/高所/閉所/", No.relaxSyntax);
169         assert((after == "閉所恐怖症"), after);
170     }
171     {
172         enum before = "asdf/fdsa";
173         immutable after = before.sedReplace("s/\\//-/", No.relaxSyntax);
174         assert((after == "asdf-fdsa"), after);
175     }
176     {
177         enum before = "HARBL";
178         immutable after = before.sedReplace("s/A/_/", No.relaxSyntax);
179         assert((after == "H_RBL"), after);
180     }
181     {
182         enum before = "there are four lights";
183         immutable after = before.sedReplace("s@ @_@g", No.relaxSyntax);
184         assert((after == "there_are_four_lights"), after);
185     }
186     {
187         enum before = "kameloso";
188         immutable after = before.sedReplace("s los bot ", No.relaxSyntax);
189         assert((after == "kameboto"), after);
190     }
191     {
192         enum before = "abc 123 def 456";
193         immutable after = before.sedReplace("s/123/789", Yes.relaxSyntax);
194         assert((after == "abc 789 def 456"), after);
195     }
196     {
197         enum before = "高所恐怖症";
198         immutable after = before.sedReplace("s/高所/閉所", Yes.relaxSyntax);
199         assert((after == "閉所恐怖症"), after);
200     }
201     {
202         enum before = "asdf/fdsa";
203         immutable after = before.sedReplace("s/\\//-", Yes.relaxSyntax);
204         assert((after == "asdf-fdsa"), after);
205     }
206     {
207         enum before = "HARBL";
208         immutable after = before.sedReplace("s/A/_/", Yes.relaxSyntax);
209         assert((after == "H_RBL"), after);
210     }
211     {
212         enum before = "kameloso";
213         immutable after = before.sedReplace("s los bot", Yes.relaxSyntax);
214         assert((after == "kameboto"), after);
215     }
216 }
217 
218 
219 // sedReplaceImpl
220 /++
221     Private sed-replace implementation.
222 
223     Works on any given character delimiter. Works with escapes.
224 
225     Params:
226         char_ = Delimiter character, generally one of [DelimiterCharacters].
227         line = Original line to apply the replacement expression to.
228         expr = Replacement expression to apply.
229         relaxSyntax = Whether or not to require the expression to end with the delimiter.
230 
231     Returns:
232         The passed line with the relevant bits replaced, or `string.init` if the
233         expression didn't apply.
234  +/
235 auto sedReplaceImpl(char char_)
236     (const string line,
237     const string expr,
238     const Flag!"relaxSyntax" relaxSyntax)
239 in (line.length, "Tried to `sedReplaceImpl` on an empty line")
240 in (expr.length, "Tried to `sedReplaceImpl` with an empty expression")
241 {
242     import lu.string : strippedRight;
243     import std.array : replace;
244     import std.string : indexOf;
245 
246     enum charAsString = "" ~ char_;
247     enum escapedCharAsString = "\\" ~ char_;
248 
249     static ptrdiff_t getNextUnescaped(const string lineWithChar)
250     {
251         string slice = lineWithChar;  // mutable
252         ptrdiff_t offset;
253         ptrdiff_t charPos = slice.indexOf(char_);
254 
255         while (charPos != -1)
256         {
257             if (charPos == 0) return offset;
258             else if (slice[charPos-1] == '\\')
259             {
260                 slice = slice[charPos+1..$];
261                 offset += (charPos + 1);
262                 charPos = slice.indexOf(char_);
263                 continue;
264             }
265             else
266             {
267                 return (offset + charPos);
268             }
269         }
270 
271         return -1;
272     }
273 
274     string slice = (char_ == ' ') ? expr : expr.strippedRight;  // mutable
275     slice = slice[2..$];  // nom 's' ~ char_
276 
277     bool global;
278 
279     if ((slice[$-2] == char_) && (slice[$-1] == 'g'))
280     {
281         slice = slice[0..$-1];
282         global = true;
283     }
284 
285     immutable openEnd = (slice[$-1] != char_);
286     if (openEnd && !relaxSyntax) return string.init;
287 
288     immutable delimPos = getNextUnescaped(slice);
289     if (delimPos == -1) return string.init;
290 
291     // Defer string-replace until after slice advance and subsequent length check
292     string replaceThis = slice[0..delimPos];  // mutable
293 
294     slice = slice[delimPos+1..$];
295     if (!slice.length) return string.init;
296 
297     // ...to here.
298     replaceThis = replaceThis.replace(escapedCharAsString, charAsString);
299 
300     immutable replaceThisPos = line.indexOf(replaceThis);
301     if (replaceThisPos == -1) return string.init;
302 
303     immutable endDelimPos = getNextUnescaped(slice);
304 
305     if (relaxSyntax)
306     {
307         if ((endDelimPos == -1) || (endDelimPos+1 == slice.length))
308         {
309             // Either there were no more delimiters or there was one at the very end
310             // Syntax is relaxed; continue
311         }
312         else
313         {
314             // Found extra delimiters, expression is malformed; abort
315             return string.init;
316         }
317     }
318     else
319     {
320         if ((endDelimPos == -1) || (endDelimPos+1 != slice.length))
321         {
322             // Either there were no more delimiters or one was found before the end
323             // Syntax is strict; abort
324             return string.init;
325         }
326     }
327 
328     immutable withThis = openEnd ? slice : slice[0..$-1];
329 
330     return global ?
331         line.replace(replaceThis, withThis) :
332         line.replace(replaceThisPos, replaceThisPos+replaceThis.length, withThis);
333 }
334 
335 ///
336 unittest
337 {
338     {
339         immutable replaced = "Hello D".sedReplaceImpl!'/'("s/Hello D/Hullo C/", No.relaxSyntax);
340         assert((replaced == "Hullo C"), replaced);
341     }
342     {
343         immutable replaced = "Hello D".sedReplaceImpl!'/'("s/l/L/g", No.relaxSyntax);
344         assert((replaced == "HeLLo D"), replaced);
345     }
346     {
347         immutable replaced = "Hello D".sedReplaceImpl!'/'("s/l/L/", No.relaxSyntax);
348         assert((replaced == "HeLlo D"), replaced);
349     }
350     {
351         immutable replaced = "I am a fish".sedReplaceImpl!'|'("s|fish|snek|g", No.relaxSyntax);
352         assert((replaced == "I am a snek"), replaced);
353     }
354     {
355         immutable replaced = "This is /a/a space".sedReplaceImpl!'/'("s/a\\//_/g", No.relaxSyntax);
356         assert((replaced == "This is /_a space"), replaced);
357     }
358     {
359         immutable replaced = "This is INVALID"
360             .sedReplaceImpl!'#'("s#asdfasdf#asdfasdf#asdfafsd#g", No.relaxSyntax);
361         assert(!replaced.length, replaced);
362     }
363     {
364         immutable replaced = "Hello D".sedReplaceImpl!'/'("s/Hello D/Hullo C", Yes.relaxSyntax);
365         assert((replaced == "Hullo C"), replaced);
366     }
367     {
368         immutable replaced = "Hello D".sedReplaceImpl!'/'("s/l/L/g", Yes.relaxSyntax);
369         assert((replaced == "HeLLo D"), replaced);
370     }
371     {
372         immutable replaced = "Hello D".sedReplaceImpl!'/'("s/l/L", Yes.relaxSyntax);
373         assert((replaced == "HeLlo D"), replaced);
374     }
375     {
376         immutable replaced = "Hello D".sedReplaceImpl!'/'("s/l/L/", Yes.relaxSyntax);
377         assert((replaced == "HeLlo D"), replaced);
378     }
379     {
380         immutable replaced = "This is INVALID".sedReplaceImpl!'#'("s#INVALID#valid##", Yes.relaxSyntax);
381         assert(!replaced.length, replaced);
382     }
383     {
384         immutable replaced = "snek".sedReplaceImpl!'/'("s/snek/", Yes.relaxSyntax);
385         assert(!replaced.length, replaced);
386     }
387     {
388         immutable replaced = "snek".sedReplaceImpl!'/'("s/snek", Yes.relaxSyntax);
389         assert(!replaced.length, replaced);
390     }
391     {
392         immutable replaced = "hink".sedReplaceImpl!'/'("s/honk/henk/", Yes.relaxSyntax);
393         assert(!replaced.length, replaced);
394     }
395 }
396 
397 
398 // onMessage
399 /++
400     Parses a channel message and looks for any sed-replace expressions therein,
401     to apply on the previous message.
402  +/
403 @(IRCEventHandler()
404     .onEvent(IRCEvent.Type.CHAN)
405     .permissionsRequired(Permissions.ignore)
406     .channelPolicy(ChannelPolicy.home)
407 )
408 void onMessage(SedReplacePlugin plugin, const ref IRCEvent event)
409 {
410     import lu.string : beginsWith, stripped;
411 
412     immutable stripped_ = event.content.stripped;
413     if (!stripped_.length) return;
414 
415     void recordLineAsLast(const string string_)
416     {
417         Line line = Line(string_, event.time);  // implicit ctor
418 
419         auto channelLines = event.channel in plugin.prevlines;
420 
421         if (!channelLines)
422         {
423             plugin.prevlines[event.channel] = typeof(plugin.prevlines[string.init]).init;
424             channelLines = event.channel in plugin.prevlines;
425         }
426 
427         auto senderLines = event.sender.nickname in *channelLines;
428 
429         if (!senderLines)
430         {
431             (*channelLines)[event.sender.nickname] = typeof((*channelLines)[string.init]).init;
432             senderLines = event.sender.nickname in *channelLines;
433             senderLines.resize(plugin.sedReplaceSettings.history);
434         }
435 
436         senderLines.put(line);
437     }
438 
439     if (stripped_.beginsWith('s') && (stripped_.length >= 5))
440     {
441         immutable delimiter = stripped_[1];
442 
443         delimiterswitch:
444         switch (delimiter)
445         {
446         static if (DelimiterCharacters.length > 1)
447         {
448             foreach (immutable c; DelimiterCharacters[1..$])
449             {
450                 case c:
451                     goto case DelimiterCharacters[0];
452             }
453         }
454 
455         case DelimiterCharacters[0]:
456             auto channelLines = event.channel in plugin.prevlines;
457             if (!channelLines) return;  // Don't save
458 
459             auto senderLines = event.sender.nickname in *channelLines;
460             if (!senderLines) return;  // As above
461 
462             // Work around CircularBuffer pre-1.2.3 having save annotated const
463             foreach (immutable line; cast()senderLines.save)
464             {
465                 import kameloso.messaging : chan;
466                 import std.format : format;
467 
468                 if (!line.content.length)
469                 {
470                     // line is Line.init
471                     continue;
472                 }
473 
474                 if ((event.time - line.timestamp) > plugin.prevlineLifetime)
475                 {
476                     // Entry is too old, any further entries will be even older
477                     break delimiterswitch;
478                 }
479 
480                 immutable result = line.content.sedReplace(
481                     event.content,
482                     cast(Flag!"relaxSyntax")plugin.sedReplaceSettings.relaxSyntax);
483 
484                 if (!result.length) continue;
485 
486                 enum pattern = "<h>%s<h> | %s";
487                 immutable message = pattern.format(event.sender.nickname, result);
488                 chan(plugin.state, event.channel, message);
489 
490                 // Record as last even if there are more lines
491                 return recordLineAsLast(result);
492             }
493 
494             // Don't save as last, this was a query
495             return;
496 
497         default:
498             // Drop down to record line
499             break;
500         }
501     }
502 
503     recordLineAsLast(stripped_);
504 }
505 
506 
507 // onWelcome
508 /++
509     Sets up a Fiber to periodically clear the lists of previous messages from
510     users once every [SedReplacePlugin.timeBetweenPurges|timeBetweenPurges].
511 
512     This is to prevent the lists from becoming huge over time.
513  +/
514 @(IRCEventHandler()
515     .onEvent(IRCEvent.Type.RPL_WELCOME)
516     .fiber(true)
517 )
518 void onWelcome(SedReplacePlugin plugin)
519 {
520     import kameloso.plugins.common.delayawait : delay;
521     import std.datetime.systime : Clock;
522 
523     delay(plugin, plugin.timeBetweenPurges, Yes.yield);
524 
525     while (true)
526     {
527         immutable now = Clock.currTime.toUnixTime;
528 
529         foreach (ref channelLines; plugin.prevlines)
530         {
531             foreach (immutable nickname, const senderLines; channelLines)
532             {
533                 if (senderLines.empty ||
534                     ((now - senderLines.front.timestamp) >= plugin.prevlineLifetime))
535                 {
536                     // Something is either wrong with the sender's entries or
537                     // the most recent entry is too old
538                     channelLines.remove(nickname);
539                 }
540             }
541         }
542 
543         delay(plugin, plugin.timeBetweenPurges, Yes.yield);
544     }
545 }
546 
547 
548 // onPart
549 /++
550     Removes the records of previous messages from a user when they leave a channel.
551  +/
552 @(IRCEventHandler()
553     .onEvent(IRCEvent.Type.PART)
554 )
555 void onPart(SedReplacePlugin plugin, const ref IRCEvent event)
556 {
557     auto channelLines = event.channel in plugin.prevlines;
558     if (!channelLines) return;
559 
560     (*channelLines).remove(event.sender.nickname);
561 }
562 
563 
564 // onQuit
565 /++
566     Removes the records of previous messages from a user when they quit.
567  +/
568 @(IRCEventHandler()
569     .onEvent(IRCEvent.Type.QUIT)
570 )
571 void onQuit(SedReplacePlugin plugin, const ref IRCEvent event)
572 {
573     foreach (ref channelLines; plugin.prevlines)
574     {
575         channelLines.remove(event.sender.nickname);
576     }
577 }
578 
579 
580 mixin MinimalAuthentication;
581 mixin PluginRegistration!SedReplacePlugin;
582 
583 public:
584 
585 
586 // SedReplacePlugin
587 /++
588     The SedReplace plugin stores a buffer of the last said line of every user,
589     and if a new message comes in with a sed-replace-like pattern in it, tries
590     to apply it on the original message as a regex-like replace.
591  +/
592 final class SedReplacePlugin : IRCPlugin
593 {
594 private:
595     import core.time : seconds;
596 
597     /// All sed-replace options gathered.
598     SedReplaceSettings sedReplaceSettings;
599 
600     /// Lifetime of a [Line] in [prevlines], in seconds.
601     enum prevlineLifetime = 3600;
602 
603     /// How often to purge the [prevlines] list of messages.
604     static immutable timeBetweenPurges = (prevlineLifetime * 3).seconds;
605 
606     /// What kind of container to use for sent lines.
607     alias BufferType = CircularBuffer!(Line, Yes.dynamic);
608 
609     /++
610         An associative arary of  [BufferType]s of the previous line(s) every user said,
611         keyed by nickname keyed by channel.
612      +/
613     BufferType[string][string] prevlines;
614 
615     mixin IRCPluginImpl;
616 }