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 }