1 /++ 2 Implementation of Printer plugin functionality that concerns formatting. 3 For internal use. 4 5 The [dialect.defs.IRCEvent|IRCEvent]-annotated handlers must be in the same module 6 as the [kameloso.plugins.printer.base.PrinterPlugin|PrinterPlugin], 7 but these implementation functions can be offloaded here to limit module size a bit. 8 9 See_Also: 10 [kameloso.plugins.printer.base], 11 [kameloso.plugins.printer.logging] 12 13 Copyright: [JR](https://github.com/zorael) 14 License: [Boost Software License 1.0](https://www.boost.org/users/license.html) 15 16 Authors: 17 [JR](https://github.com/zorael) 18 +/ 19 module kameloso.plugins.printer.formatting; 20 21 version(WithPrinterPlugin): 22 23 private: 24 25 import kameloso.plugins.printer.base; 26 27 import kameloso.pods : CoreSettings; 28 import dialect.defs; 29 import std.range.primitives : isOutputRange; 30 import std.typecons : Flag, No, Yes; 31 32 version(Colours) import kameloso.terminal.colours.defs : TerminalForeground; 33 34 package: 35 36 @safe: 37 38 version(Colours) 39 { 40 alias TF = TerminalForeground; 41 42 /++ 43 Default colours for printing events on a dark terminal background. 44 +/ 45 enum EventPrintingDark : TerminalForeground 46 { 47 type = TF.lightblue, 48 error = TF.lightred, 49 sender = TF.lightgreen, 50 target = TF.cyan, 51 channel = TF.yellow, 52 content = TF.default_, 53 aux = TF.darkgrey, 54 count = TF.green, 55 num = TF.darkgrey, 56 badge = TF.white, 57 emote = TF.cyan, 58 highlight = TF.white, 59 query = TF.lightgreen, 60 } 61 62 /++ 63 Default colours for printing events on a bright terminal background. 64 +/ 65 enum EventPrintingBright : TerminalForeground 66 { 67 type = TF.blue, 68 error = TF.red, 69 sender = TF.green, 70 target = TF.cyan, 71 channel = TF.yellow, 72 content = TF.default_, 73 aux = TF.default_, 74 count = TF.lightgreen, 75 num = TF.default_, 76 badge = TF.black, 77 emote = TF.lightcyan, 78 highlight = TF.black, 79 query = TF.green, 80 } 81 } 82 83 84 // put 85 /++ 86 Puts a variadic list of values into an output range sink. 87 88 Params: 89 colours = Whether or not to accept terminal colour tokens and use 90 them to tint the text. 91 sink = Output range to sink items into. 92 args = Variadic list of things to put into the output range. 93 +/ 94 void put(Flag!"colours" colours = No.colours, Sink, Args...) 95 (auto ref Sink sink, Args args) 96 if (isOutputRange!(Sink, char[])) 97 { 98 foreach (arg; args) 99 { 100 alias T = typeof(arg); 101 102 static if (__traits(compiles, sink.put(T.init)) && !is(T : bool)) 103 { 104 sink.put(arg); 105 } 106 else static if (is(T == enum)) 107 { 108 import lu.conv : Enum; 109 import std.traits : Unqual; 110 111 sink.put(Enum!(Unqual!T).toString(arg)); 112 } 113 else static if (is(T : bool)) 114 { 115 sink.put(arg ? "true" : "false"); 116 } 117 else static if (is(T : long)) 118 { 119 import lu.conv : toAlphaInto; 120 arg.toAlphaInto(sink); 121 } 122 else 123 { 124 import std.conv : to; 125 sink.put(arg.to!string); 126 } 127 } 128 } 129 130 /// 131 unittest 132 { 133 import std.array : Appender; 134 135 Appender!(char[]) sink; 136 137 .put(sink, "abc", long.min, "def", 456, true); 138 assert((sink.data == "abc-9223372036854775808def456true"), sink.data); 139 } 140 141 142 // formatMessageMonochrome 143 /++ 144 Formats an [dialect.defs.IRCEvent|IRCEvent] into an output range sink, in monochrome. 145 146 It formats the timestamp, the type of the event, the sender or sender alias, 147 the channel or target, the content body, as well as auxiliary information. 148 149 Params: 150 plugin = Current [kameloso.plugins.printer.base.PrinterPlugin|PrinterPlugin]. 151 sink = Output range to format the [dialect.defs.IRCEvent|IRCEvent] into. 152 event = The [dialect.defs.IRCEvent|IRCEvent] that is to be formatted. 153 bellOnMention = Whether or not to emit a terminal bell when the bot's 154 nickname is mentioned in chat. 155 bellOnError = Whether or not to emit a terminal bell when an error occurred. 156 +/ 157 void formatMessageMonochrome(Sink) 158 (PrinterPlugin plugin, 159 auto ref Sink sink, 160 const ref IRCEvent event, 161 const Flag!"bellOnMention" bellOnMention, 162 const Flag!"bellOnError" bellOnError) 163 if (isOutputRange!(Sink, char[])) 164 { 165 import kameloso.irccolours : stripEffects; 166 import lu.conv : Enum; 167 import std.algorithm.comparison : equal; 168 import std.algorithm.iteration : filter; 169 import std.datetime : DateTime; 170 import std.datetime.systime : SysTime; 171 import std.format : formattedWrite; 172 import std.uni : asLowerCase; 173 174 immutable typestring = Enum!(IRCEvent.Type).toString(event.type).withoutTypePrefix; 175 string content = stripEffects(event.content); // mutable 176 bool shouldBell; 177 178 static if (!__traits(hasMember, Sink, "data")) 179 { 180 scope(exit) 181 { 182 sink.put('\n'); 183 } 184 } 185 186 void putSender() 187 { 188 if (event.sender.isServer) 189 { 190 sink.put(event.sender.address); 191 return; 192 } 193 194 bool putDisplayName; 195 196 version(TwitchSupport) 197 { 198 if (event.sender.displayName.length) 199 { 200 sink.put(event.sender.displayName); 201 putDisplayName = true; 202 203 if ((event.sender.displayName != event.sender.nickname) && 204 !event.sender.displayName.asLowerCase.equal(event.sender.nickname)) 205 { 206 .put(sink, " (", event.sender.nickname, ')'); 207 } 208 } 209 } 210 211 if (!putDisplayName && event.sender.nickname.length) 212 { 213 // Can be no-nick special: [PING] *2716423853 214 sink.put(event.sender.nickname); 215 } 216 217 version(PrintClassNamesToo) 218 { 219 .put(sink, ':', event.sender.class_); 220 } 221 222 version(PrintAccountNamesToo) 223 { 224 // No need to check for nickname.length, I think 225 if ((plugin.state.server.daemon != IRCServer.Daemon.twitch) && 226 event.sender.account.length) 227 { 228 .put(sink, '(', event.sender.account, ')'); 229 } 230 } 231 232 version(TwitchSupport) 233 { 234 if ((plugin.state.server.daemon == IRCServer.Daemon.twitch) && 235 plugin.printerSettings.twitchBadges && event.sender.badges.length) 236 { 237 with (IRCEvent.Type) 238 switch (event.type) 239 { 240 case JOIN: 241 case SELFJOIN: 242 case PART: 243 case SELFPART: 244 case QUERY: 245 //case SELFQUERY: // Doesn't seem to happen 246 break; 247 248 default: 249 .put(sink, " [", event.sender.badges, ']'); 250 break; 251 } 252 } 253 } 254 } 255 256 void putTarget() 257 { 258 bool putArrow; 259 bool putDisplayName; 260 261 version(TwitchSupport) 262 { 263 with (IRCEvent.Type) 264 switch (event.type) 265 { 266 case TWITCH_GIFTCHAIN: 267 // Add more as they become apparent 268 sink.put(" <- "); 269 break; 270 271 default: 272 sink.put(" -> "); 273 break; 274 } 275 276 putArrow = true; 277 278 if (event.target.displayName.length) 279 { 280 sink.put(event.target.displayName); 281 putDisplayName = true; 282 283 if ((event.target.displayName != event.target.nickname) && 284 !event.target.displayName.asLowerCase.equal(event.target.nickname)) 285 { 286 .put(sink, " (", event.target.nickname, ')'); 287 } 288 } 289 } 290 291 if (!putArrow) 292 { 293 sink.put(" -> "); 294 } 295 296 if (!putDisplayName) 297 { 298 sink.put(event.target.nickname); 299 } 300 301 version(PrintClassNamesToo) 302 { 303 .put(sink, ':', event.target.class_); 304 } 305 306 version(PrintAccountNamesToo) 307 { 308 // No need to check for nickname.length, I think 309 if ((plugin.state.server.daemon != IRCServer.Daemon.twitch) && 310 event.target.account.length) 311 { 312 .put(sink, '(', event.target.account, ')'); 313 } 314 } 315 316 version(TwitchSupport) 317 { 318 if ((plugin.state.server.daemon == IRCServer.Daemon.twitch) && 319 plugin.printerSettings.twitchBadges && event.target.badges.length) 320 { 321 .put(sink, " [", event.target.badges, ']'); 322 } 323 } 324 } 325 326 void putContent() 327 { 328 if (event.sender.isServer || event.sender.nickname.length) 329 { 330 immutable isEmote = (event.type == IRCEvent.Type.EMOTE) || 331 (event.type == IRCEvent.Type.SELFEMOTE); 332 333 if (isEmote) 334 { 335 sink.put(' '); 336 } 337 else 338 { 339 sink.put(`: "`); 340 } 341 342 with (IRCEvent.Type) 343 switch (event.type) 344 { 345 case CHAN: 346 case EMOTE: 347 case TWITCH_SUBGIFT: 348 if (plugin.state.client.nickname.length && 349 content.containsNickname(plugin.state.client.nickname)) 350 { 351 // Nick was mentioned (certain) 352 shouldBell = bellOnMention; 353 } 354 break; 355 356 default: 357 break; 358 } 359 360 sink.put(content); 361 if (!isEmote) sink.put('"'); 362 } 363 else 364 { 365 // PING or ERROR likely 366 sink.put(content); // No need for indenting space 367 } 368 } 369 370 sink.put('['); 371 372 (cast(DateTime)SysTime 373 .fromUnixTime(event.time)) 374 .timeOfDay 375 .toString(sink); 376 377 sink.put("] ["); 378 379 if (plugin.printerSettings.uppercaseTypes) 380 { 381 sink.put(typestring); 382 } 383 else 384 { 385 sink.put(typestring.asLowerCase); 386 } 387 388 sink.put("] "); 389 390 if (event.channel.length) .put(sink, '[', event.channel, "] "); 391 392 putSender(); 393 394 bool putQuotedTwitchMessage; 395 auto auxRange = event.aux[].filter!(s => s.length); 396 397 version(TwitchSupport) 398 { 399 if (((event.type == IRCEvent.Type.CHAN) || 400 (event.type == IRCEvent.Type.SELFCHAN) || 401 (event.type == IRCEvent.Type.EMOTE)) && 402 event.target.nickname.length && 403 event.aux[0].length) 404 { 405 /*if (content.length)*/ putContent(); 406 putTarget(); 407 .put(sink, `: "`, event.aux[0], '"'); 408 409 putQuotedTwitchMessage = true; 410 auxRange.popFront(); 411 } 412 } 413 414 if (!putQuotedTwitchMessage) 415 { 416 if (event.target.nickname.length) putTarget(); 417 if (content.length) putContent(); 418 } 419 420 if (!auxRange.empty) 421 { 422 enum pattern = " (%-(%s%|) (%))"; 423 424 static if ((__VERSION__ == 2101L) || (__VERSION__ == 2102L)) 425 { 426 import std.array : array; 427 // "Deprecation: scope variable `aux` assigned to non-scope parameter `_param_2` calling `formattedWrite" 428 // Seemingly only on 2.101 and 2.102 429 sink.formattedWrite(pattern, auxRange.array.dup); 430 } 431 else 432 { 433 sink.formattedWrite(pattern, auxRange); 434 } 435 } 436 437 auto countRange = event.count[].filter!(n => !n.isNull); 438 439 if (!countRange.empty) 440 { 441 enum pattern = " {%-(%s%|} {%)}"; 442 sink.formattedWrite(pattern, countRange); 443 } 444 445 if (event.num > 0) 446 { 447 import lu.conv : toAlphaInto; 448 449 sink.put(" [#"); 450 event.num.toAlphaInto!(3, 3)(sink); 451 sink.put(']'); 452 } 453 454 if (event.errors.length) 455 { 456 .put(sink, " ! ", event.errors, " !"); 457 } 458 459 shouldBell = shouldBell || 460 ((event.type == IRCEvent.Type.QUERY) && bellOnMention) || 461 (event.errors.length && bellOnError); 462 463 if (shouldBell) sink.put(plugin.bell); 464 } 465 466 /// 467 @system unittest 468 { 469 import kameloso.plugins.common.core : IRCPluginState; 470 import std.array : Appender; 471 472 Appender!(char[]) sink; 473 474 IRCPluginState state; 475 state.server.daemon = IRCServer.Daemon.twitch; 476 PrinterPlugin plugin = new PrinterPlugin(state); 477 478 IRCEvent event; 479 480 with (event.sender) 481 { 482 nickname = "nickname"; 483 address = "127.0.0.1"; 484 version(TwitchSupport) displayName = "Nickname"; 485 //account = "n1ckn4m3"; 486 class_ = IRCUser.Class.whitelist; 487 } 488 489 event.type = IRCEvent.Type.JOIN; 490 event.channel = "#channel"; 491 492 formatMessageMonochrome(plugin, sink, event, No.bellOnMention, No.bellOnError); 493 immutable joinLine = sink.data[11..$].idup; 494 version(TwitchSupport) assert((joinLine == "[join] [#channel] Nickname"), joinLine); 495 else assert((joinLine == "[join] [#channel] nickname"), joinLine); 496 sink.clear(); 497 498 event.type = IRCEvent.Type.CHAN; 499 event.content = "Harbl snarbl"; 500 501 formatMessageMonochrome(plugin, sink, event, No.bellOnMention, No.bellOnError); 502 immutable chanLine = sink.data[11..$].idup; 503 version(TwitchSupport) assert((chanLine == `[chan] [#channel] Nickname: "Harbl snarbl"`), chanLine); 504 else assert((chanLine == `[chan] [#channel] nickname: "Harbl snarbl"`), chanLine); 505 sink.clear(); 506 507 version(TwitchSupport) 508 { 509 event.sender.badges = "broadcaster/0,moderator/1,subscriber/9"; 510 //colour = "#3c507d"; 511 512 formatMessageMonochrome(plugin, sink, event, No.bellOnMention, No.bellOnError); 513 immutable twitchLine = sink.data[11..$].idup; 514 assert((twitchLine == `[chan] [#channel] Nickname [broadcaster/0,moderator/1,subscriber/9]: "Harbl snarbl"`), 515 twitchLine); 516 sink.clear(); 517 event.sender.badges = string.init; 518 } 519 520 event.type = IRCEvent.Type.ACCOUNT; 521 event.channel = string.init; 522 event.content = string.init; 523 event.sender.account = "n1ckn4m3"; 524 event.aux[0] = "n1ckn4m3"; 525 526 formatMessageMonochrome(plugin, sink, event, No.bellOnMention, No.bellOnError); 527 immutable accountLine = sink.data[11..$].idup; 528 version(TwitchSupport) assert((accountLine == "[account] Nickname (n1ckn4m3)"), accountLine); 529 else assert((accountLine == "[account] nickname (n1ckn4m3)"), accountLine); 530 sink.clear(); 531 532 event.errors = "DANGER WILL ROBINSON"; 533 event.content = "Blah balah"; 534 event.num = 666; 535 event.count[0] = -42; 536 event.count[1] = 123; 537 event.count[5] = 420; 538 event.aux[0] = string.init; 539 event.aux[1] = "aux1"; 540 event.aux[4] = "aux5"; 541 event.type = IRCEvent.Type.ERROR; 542 543 formatMessageMonochrome(plugin, sink, event, No.bellOnMention, No.bellOnError); 544 immutable errorLine = sink.data[11..$].idup; 545 version(TwitchSupport) 546 { 547 enum expected = `[error] Nickname: "Blah balah" (aux1) (aux5) ` ~ 548 "{-42} {123} {420} [#666] ! DANGER WILL ROBINSON !"; 549 assert((errorLine == expected), errorLine); 550 } 551 else 552 { 553 enum expected = `[error] nickname: "Blah balah" (aux1) (aux5) ` ~ 554 "{-42} {123} {420} [#666] ! DANGER WILL ROBINSON !"; 555 assert((errorLine == expected), errorLine); 556 } 557 //sink.clear(); 558 } 559 560 561 // formatMessageColoured 562 /++ 563 Formats an [dialect.defs.IRCEvent|IRCEvent] into an output range sink, coloured. 564 565 It formats the timestamp, the type of the event, the sender or the sender's 566 display name, the channel or target, the content body, as well as auxiliary 567 information and numbers. 568 569 Params: 570 plugin = Current [kameloso.plugins.printer.base.PrinterPlugin|PrinterPlugin]. 571 sink = Output range to format the [dialect.defs.IRCEvent|IRCEvent] into. 572 event = The [dialect.defs.IRCEvent|IRCEvent] that is to be formatted. 573 bellOnMention = Whether or not to emit a terminal bell when the bot's 574 nickname is mentioned in chat. 575 bellOnError = Whether or not to emit a terminal bell when an error occurred. 576 +/ 577 version(Colours) 578 void formatMessageColoured(Sink) 579 (PrinterPlugin plugin, 580 auto ref Sink sink, 581 const ref IRCEvent event, 582 const Flag!"bellOnMention" bellOnMention, 583 const Flag!"bellOnError" bellOnError) 584 if (isOutputRange!(Sink, char[])) 585 { 586 import kameloso.constants : DefaultColours; 587 import kameloso.terminal.colours.defs : ANSICodeType, TerminalReset; 588 import kameloso.terminal.colours : applyANSI; 589 import lu.conv : Enum; 590 import std.algorithm.iteration : filter; 591 import std.datetime : DateTime; 592 import std.datetime.systime : SysTime; 593 import std.format : formattedWrite; 594 import std.uni : asLowerCase; 595 596 alias Bright = EventPrintingBright; 597 alias Dark = EventPrintingDark; 598 alias Timestamp = DefaultColours.TimestampColour; 599 600 immutable rawTypestring = Enum!(IRCEvent.Type).toString(event.type); 601 immutable typestring = rawTypestring.withoutTypePrefix; 602 string content = event.content; // mutable, don't strip 603 bool shouldBell; 604 605 immutable bright = cast(Flag!"brightTerminal")plugin.state.settings.brightTerminal; 606 607 version(TwitchSupport) 608 { 609 immutable normalise = cast(Flag!"normalise")plugin.printerSettings.normaliseTruecolour; 610 } 611 612 /++ 613 Outputs a terminal ANSI colour token based on the hash of the passed 614 nickname. 615 616 It gives each user a random yet consistent colour to their name. 617 +/ 618 uint colourByHash(const string nickname) 619 { 620 import kameloso.irccolours : ircANSIColourMap; 621 import kameloso.terminal.colours : getColourByHash; 622 623 if (!plugin.printerSettings.colourfulNicknames) 624 { 625 // Don't differentiate between sender and target? Consistency? 626 return plugin.state.settings.brightTerminal ? Bright.sender : Dark.sender; 627 } 628 629 return getColourByHash(nickname, plugin.state.settings); 630 } 631 632 /++ 633 Outputs a terminal truecolour token based on the #RRGGBB value stored in 634 `user.colour`. 635 636 This is for Twitch servers that assign such values to users' messages. 637 By catching it we can honour the setting by tinting users accordingly. 638 +/ 639 void colourUserTruecolour(const IRCUser user) 640 { 641 bool coloured; 642 643 version(TwitchSupport) 644 { 645 if (!user.isServer && 646 user.colour.length && 647 plugin.printerSettings.truecolour && 648 plugin.state.settings.extendedColours) 649 { 650 import kameloso.terminal.colours : applyTruecolour; 651 import lu.conv : rgbFromHex; 652 653 auto rgb = rgbFromHex(user.colour); 654 sink.applyTruecolour(rgb.r, rgb.g, rgb.b, bright, normalise); 655 coloured = true; 656 } 657 } 658 659 if (!coloured) 660 { 661 immutable name = user.isServer ? 662 user.address : 663 ((user.account.length && plugin.printerSettings.colourByAccount) ? 664 user.account : 665 user.nickname); 666 667 sink.applyANSI(colourByHash(name), ANSICodeType.foreground); 668 } 669 } 670 671 static if (!__traits(hasMember, Sink, "data")) 672 { 673 scope(exit) 674 { 675 sink.put('\n'); 676 } 677 } 678 679 void putSender() 680 { 681 scope(exit) sink.applyANSI(TerminalReset.all); 682 683 colourUserTruecolour(event.sender); 684 685 if (event.sender.isServer) 686 { 687 sink.put(event.sender.address); 688 return; 689 } 690 691 bool putDisplayName; 692 693 version(TwitchSupport) 694 { 695 if (event.sender.displayName.length) 696 { 697 sink.put(event.sender.displayName); 698 putDisplayName = true; 699 700 import std.algorithm.comparison : equal; 701 import std.uni : asLowerCase; 702 703 if ((event.sender.displayName != event.sender.nickname) && 704 !event.sender.displayName.asLowerCase.equal(event.sender.nickname)) 705 { 706 sink.applyANSI(TerminalReset.all); 707 sink.put(" ("); 708 colourUserTruecolour(event.sender); 709 sink.put(event.sender.nickname); 710 sink.applyANSI(TerminalReset.all); 711 sink.put(')'); 712 } 713 } 714 } 715 716 if (!putDisplayName && event.sender.nickname.length) 717 { 718 // Can be no-nick special: [PING] *2716423853 719 sink.put(event.sender.nickname); 720 } 721 722 version(PrintClassNamesToo) 723 { 724 sink.applyANSI(TerminalReset.all); 725 .put(sink, ':', event.sender.class_); 726 } 727 728 version(PrintAccountNamesToo) 729 { 730 // No need to check for nickname.length, I think 731 if ((plugin.state.server.daemon != IRCServer.Daemon.twitch) && 732 event.sender.account.length) 733 { 734 sink.applyANSI(TerminalReset.all); 735 .put(sink, '(', event.sender.account, ')'); 736 } 737 } 738 739 version(TwitchSupport) 740 { 741 if ((plugin.state.server.daemon == IRCServer.Daemon.twitch) && 742 plugin.printerSettings.twitchBadges && 743 event.sender.badges.length) 744 { 745 with (IRCEvent.Type) 746 switch (event.type) 747 { 748 case JOIN: 749 case SELFJOIN: 750 case PART: 751 case SELFPART: 752 break; 753 754 default: 755 immutable code = bright ? Bright.badge : Dark.badge; 756 sink.applyANSI(TerminalReset.all); 757 sink.applyANSI(code, ANSICodeType.foreground); 758 .put(sink, " [", event.sender.badges, ']'); 759 break; 760 } 761 } 762 } 763 } 764 765 void putTarget() 766 { 767 scope(exit) sink.applyANSI(TerminalReset.all, ANSICodeType.reset); 768 769 bool putArrow; 770 bool putDisplayName; 771 772 version(TwitchSupport) 773 { 774 with (IRCEvent.Type) 775 switch (event.type) 776 { 777 case TWITCH_GIFTCHAIN: 778 // Add more as they become apparent 779 sink.applyANSI(TerminalReset.all); 780 sink.put(" <- "); 781 break; 782 783 default: 784 sink.applyANSI(TerminalReset.all); 785 sink.put(" -> "); 786 break; 787 } 788 789 colourUserTruecolour(event.target); 790 putArrow = true; 791 792 if (event.target.displayName.length) 793 { 794 sink.put(event.target.displayName); 795 putDisplayName = true; 796 797 import std.algorithm.comparison : equal; 798 import std.uni : asLowerCase; 799 800 if ((event.target.displayName != event.target.nickname) && 801 !event.target.displayName.asLowerCase.equal(event.target.nickname)) 802 { 803 sink.put(" ("); 804 colourUserTruecolour(event.target); 805 sink.put(event.target.nickname); 806 sink.applyANSI(TerminalReset.all); 807 sink.put(')'); 808 } 809 } 810 } 811 812 if (!putArrow) 813 { 814 // No need to check isServer; target is never server 815 sink.applyANSI(TerminalReset.all); 816 sink.put(" -> "); 817 colourUserTruecolour(event.target); 818 } 819 820 if (!putDisplayName) 821 { 822 sink.put(event.target.nickname); 823 } 824 825 version(PrintClassNamesToo) 826 { 827 sink.applyANSI(TerminalReset.all); 828 .put(sink, ':', event.target.class_); 829 } 830 831 version(PrintAccountNamesToo) 832 { 833 // No need to check for nickname.length, I think 834 if ((plugin.state.server.daemon != IRCServer.Daemon.twitch) && 835 event.target.account.length) 836 { 837 sink.applyANSI(TerminalReset.all); 838 sink.put('(', event.target.account, ')'); 839 } 840 } 841 842 version(TwitchSupport) 843 { 844 if ((plugin.state.server.daemon == IRCServer.Daemon.twitch) && 845 plugin.printerSettings.twitchBadges && 846 event.target.badges.length) 847 { 848 immutable code = bright ? Bright.badge : Dark.badge; 849 sink.applyANSI(TerminalReset.all); 850 sink.applyANSI(code, ANSICodeType.foreground); 851 .put(sink, " [", event.target.badges, ']'); 852 } 853 } 854 } 855 856 void putContent() 857 { 858 import kameloso.terminal.colours.defs : ANSICodeType, TerminalBackground, TerminalForeground; 859 import kameloso.terminal.colours : applyANSI; 860 861 scope(exit) sink.applyANSI(TerminalReset.all); 862 863 immutable TerminalForeground contentFgBase = bright ? Bright.content : Dark.content; 864 immutable TerminalForeground emoteFgBase = bright ? Bright.emote : Dark.emote; 865 immutable isEmote = 866 (event.type == IRCEvent.Type.EMOTE) || 867 (event.type == IRCEvent.Type.SELFEMOTE); 868 immutable fgBase = isEmote ? emoteFgBase : contentFgBase; 869 870 sink.applyANSI(fgBase, ANSICodeType.foreground); // Always grey colon and SASL +, prepare for emote 871 872 if (!event.sender.isServer && !event.sender.nickname.length) 873 { 874 // PING or ERROR likely 875 sink.put(content); // No need for delimiter space 876 return; 877 } 878 879 if (isEmote) 880 { 881 sink.put(' '); 882 } 883 else 884 { 885 sink.put(`: "`); 886 } 887 888 if (plugin.state.server.daemon != IRCServer.Daemon.twitch) 889 { 890 import kameloso.irccolours : mapEffects; 891 // Twitch chat has no colours or effects, only emotes 892 content = mapEffects(content, fgBase); 893 } 894 else 895 { 896 version(TwitchSupport) 897 { 898 content = highlightEmotes( 899 event, 900 cast(Flag!"colourful")plugin.printerSettings.colourfulEmotes, 901 plugin.state.settings); 902 } 903 } 904 905 with (IRCEvent.Type) 906 switch (event.type) 907 { 908 case CHAN: 909 case EMOTE: 910 case TWITCH_SUBGIFT: 911 //case SELFCHAN: 912 import kameloso.terminal.colours : invert; 913 914 /// Nick was mentioned (certain) 915 bool match; 916 string inverted = content; 917 918 if (content.containsNickname(plugin.state.client.nickname)) 919 { 920 inverted = content.invert(plugin.state.client.nickname); 921 match = true; 922 } 923 924 version(TwitchSupport) 925 { 926 // If available, also highlight the display name alias 927 if (plugin.state.client.displayName.length && 928 (plugin.state.client.nickname != plugin.state.client.displayName) && 929 content.containsNickname(plugin.state.client.displayName)) 930 { 931 inverted = inverted.invert(plugin.state.client.displayName); 932 match = true; 933 } 934 } 935 936 if (!match) goto default; 937 938 sink.put(inverted); 939 shouldBell = bellOnMention; 940 break; 941 942 default: 943 // Normal non-highlighting channel message 944 sink.put(content); 945 break; 946 } 947 948 // Reset the background to ward off bad backgrounds bleeding out 949 sink.applyANSI(fgBase, ANSICodeType.foreground); //, TerminalBackground.default_); 950 sink.applyANSI(TerminalBackground.default_); 951 if (!isEmote) sink.put('"'); 952 } 953 954 immutable timestampCode = bright ? Timestamp.bright : Timestamp.dark; 955 sink.applyANSI(timestampCode, ANSICodeType.foreground); 956 sink.put('['); 957 958 (cast(DateTime)SysTime 959 .fromUnixTime(event.time)) 960 .timeOfDay 961 .toString(sink); 962 963 sink.put(']'); 964 965 import lu.string : beginsWith; 966 967 if ((event.type == IRCEvent.Type.ERROR) || 968 (event.type == IRCEvent.Type.TWITCH_ERROR) || 969 rawTypestring.beginsWith("ERR_")) 970 { 971 sink.applyANSI(bright ? Bright.error : Dark.error); 972 } 973 else 974 { 975 if (bright) 976 { 977 immutable code = (event.type == IRCEvent.Type.QUERY) ? Bright.query : Bright.type; 978 sink.applyANSI(code, ANSICodeType.foreground); 979 } 980 else 981 { 982 immutable code = (event.type == IRCEvent.Type.QUERY) ? Dark.query : Dark.type; 983 sink.applyANSI(code, ANSICodeType.foreground); 984 } 985 } 986 987 sink.put(" ["); 988 989 if (plugin.printerSettings.uppercaseTypes) 990 { 991 sink.put(typestring); 992 } 993 else 994 { 995 sink.put(typestring.asLowerCase); 996 } 997 998 sink.put("] "); 999 1000 if (event.channel.length) 1001 { 1002 immutable code = bright ? Bright.channel : Dark.channel; 1003 sink.applyANSI(code, ANSICodeType.foreground); 1004 .put(sink, '[', event.channel, "] "); 1005 } 1006 1007 putSender(); 1008 1009 bool putQuotedTwitchMessage; 1010 auto auxRange = event.aux[].filter!(s => s.length); 1011 1012 version(TwitchSupport) 1013 { 1014 if (((event.type == IRCEvent.Type.CHAN) || 1015 (event.type == IRCEvent.Type.SELFCHAN) || 1016 (event.type == IRCEvent.Type.EMOTE)) && 1017 event.target.nickname.length && 1018 event.aux[0].length) 1019 { 1020 /*if (content.length)*/ putContent(); 1021 putTarget(); 1022 immutable code = bright ? Bright.content : Dark.content; 1023 sink.applyANSI(code, ANSICodeType.foreground); 1024 .put(sink, `: "`, event.aux[0], '"'); 1025 1026 putQuotedTwitchMessage = true; 1027 auxRange.popFront(); 1028 } 1029 } 1030 1031 if (!putQuotedTwitchMessage) 1032 { 1033 if (event.target.nickname.length) putTarget(); 1034 if (content.length) putContent(); 1035 } 1036 1037 if (!auxRange.empty) 1038 { 1039 enum pattern = " (%-(%s%|) (%))"; 1040 sink.applyANSI(bright ? Bright.aux : Dark.aux); 1041 1042 static if ((__VERSION__ == 2101L) || (__VERSION__ == 2102L)) 1043 { 1044 import std.array : array; 1045 // "Deprecation: scope variable `aux` assigned to non-scope parameter `_param_2` calling `formattedWrite" 1046 // Seemingly only on 2.101 and 2.102 1047 sink.formattedWrite(pattern, auxRange.array.dup); 1048 } 1049 else 1050 { 1051 sink.formattedWrite(pattern, auxRange); 1052 } 1053 } 1054 1055 auto countRange = event.count[].filter!(n => !n.isNull); 1056 1057 if (!countRange.empty) 1058 { 1059 enum pattern = " {%-(%s%|} {%)}"; 1060 sink.applyANSI(bright ? Bright.count : Dark.count); 1061 sink.formattedWrite(pattern, countRange); 1062 } 1063 1064 if (event.num > 0) 1065 { 1066 import lu.conv : toAlphaInto; 1067 1068 sink.applyANSI(bright ? Bright.num : Dark.num); 1069 sink.put(" [#"); 1070 event.num.toAlphaInto!(3, 3)(sink); 1071 sink.put(']'); 1072 } 1073 1074 if (event.errors.length) 1075 { 1076 immutable code = bright ? Bright.error : Dark.error; 1077 sink.applyANSI(code, ANSICodeType.foreground); 1078 .put(sink, " ! ", event.errors, " !"); 1079 } 1080 1081 sink.applyANSI(TerminalReset.all); 1082 1083 shouldBell = shouldBell || 1084 ((event.type == IRCEvent.Type.QUERY) && bellOnMention) || 1085 (event.errors.length && bellOnError); 1086 1087 if (shouldBell) sink.put(plugin.bell); 1088 } 1089 1090 1091 // withoutTypePrefix 1092 /++ 1093 Slices away any type prefixes from the string of a 1094 [dialect.defs.IRCEvent.Type|IRCEvent.Type]. 1095 1096 Only for shared use in [formatMessageMonochrome] and [formatMessageColoured]. 1097 1098 Example: 1099 --- 1100 immutable typestring1 = "PRIVMSG".withoutTypePrefix; 1101 assert((typestring1 == "PRIVMSG"), typestring1); // passed through 1102 1103 immutable typestring2 = "ERR_NOSUCHNICK".withoutTypePrefix; 1104 assert((typestring2 == "NOSUCHNICK"), typestring2); 1105 1106 immutable typestring3 = "RPL_LIST".withoutTypePrefix; 1107 assert((typestring3 == "LIST"), typestring3); 1108 --- 1109 1110 Params: 1111 typestring = The string form of a [dialect.defs.IRCEvent.Type|IRCEvent.Type]. 1112 1113 Returns: 1114 A slice of the passed `typestring`, excluding any prefixes if present. 1115 +/ 1116 auto withoutTypePrefix(const string typestring) @safe pure nothrow @nogc @property 1117 { 1118 import lu.string : beginsWith; 1119 1120 if (typestring.beginsWith("RPL_") || typestring.beginsWith("ERR_")) 1121 { 1122 return typestring[4..$]; 1123 } 1124 else 1125 { 1126 version(TwitchSupport) 1127 { 1128 if (typestring.beginsWith("TWITCH_")) 1129 { 1130 return typestring[7..$]; 1131 } 1132 } 1133 } 1134 1135 return typestring; // as is 1136 } 1137 1138 /// 1139 unittest 1140 { 1141 { 1142 immutable typestring = "RPL_ENDOFMOTD"; 1143 immutable without = typestring.withoutTypePrefix; 1144 assert((without == "ENDOFMOTD"), without); 1145 } 1146 { 1147 immutable typestring = "ERR_CHANOPRIVSNEEDED"; 1148 immutable without = typestring.withoutTypePrefix; 1149 assert((without == "CHANOPRIVSNEEDED"), without); 1150 } 1151 version(TwitchSupport) 1152 {{ 1153 immutable typestring = "TWITCH_USERSTATE"; 1154 immutable without = typestring.withoutTypePrefix; 1155 assert((without == "USERSTATE"), without); 1156 }} 1157 { 1158 immutable typestring = "PRIVMSG"; 1159 immutable without = typestring.withoutTypePrefix; 1160 assert((without == "PRIVMSG"), without); 1161 } 1162 } 1163 1164 1165 // highlightEmotes 1166 /++ 1167 Tints emote strings and highlights Twitch emotes in a ref 1168 [dialect.defs.IRCEvent|IRCEvent]'s `content` member. 1169 1170 Wraps [highlightEmotesImpl]. 1171 1172 Params: 1173 event = [dialect.defs.IRCEvent|IRCEvent] whose content text to highlight. 1174 colourful = Whether or not emotes should be highlit in colours. 1175 brightTerminal = Whether or not the terminal has a bright background 1176 and colours should be adapted to suit. 1177 1178 Returns: 1179 A new string of the passed [dialect.defs.IRCEvent|IRCEvent]'s `content` member 1180 with any emotes highlighted, or said `content` member as-is if there weren't any. 1181 +/ 1182 version(Colours) 1183 version(TwitchSupport) 1184 auto highlightEmotes( 1185 const ref IRCEvent event, 1186 const Flag!"colourful" colourful, 1187 const CoreSettings settings) 1188 { 1189 import kameloso.constants : DefaultColours; 1190 import kameloso.terminal.colours : applyANSI; 1191 import lu.string : contains; 1192 import std.array : Appender; 1193 1194 alias Bright = EventPrintingBright; 1195 alias Dark = EventPrintingDark; 1196 1197 if (!event.emotes.length) return event.content; 1198 1199 static Appender!(char[]) sink; 1200 scope(exit) sink.clear(); 1201 sink.reserve(event.content.length + 60); // mostly +10 1202 1203 immutable TerminalForeground highlight = settings.brightTerminal ? 1204 Bright.highlight : 1205 Dark.highlight; 1206 immutable isEmoteOnly = !colourful && event.tags.contains("emote-only=1"); 1207 1208 with (IRCEvent.Type) 1209 switch (event.type) 1210 { 1211 case EMOTE: 1212 case SELFEMOTE: 1213 if (isEmoteOnly) 1214 { 1215 // Just highlight the whole line, don't worry about resetting to fgBase 1216 sink.applyANSI(highlight); 1217 sink.put(event.content); 1218 break; 1219 } 1220 1221 // Emote but mixed text and emotes OR we're doing colourful emotes 1222 immutable TerminalForeground emoteFgBase = settings.brightTerminal ? 1223 Bright.emote : 1224 Dark.emote; 1225 1226 sink.highlightEmotesImpl( 1227 event.content, 1228 event.emotes, 1229 highlight, 1230 emoteFgBase, 1231 colourful, 1232 settings); 1233 break; 1234 1235 default: 1236 if (isEmoteOnly) 1237 { 1238 // / Emote only channel message, treat the same as an emote-only emote? 1239 goto case EMOTE; 1240 } 1241 1242 // Normal content, normal text, normal emotes 1243 immutable TerminalForeground contentFgBase = settings.brightTerminal ? 1244 Bright.content : 1245 Dark.content; 1246 1247 sink.highlightEmotesImpl( 1248 event.content, 1249 event.emotes, 1250 highlight, 1251 contentFgBase, 1252 colourful, 1253 settings); 1254 break; 1255 } 1256 1257 return sink.data.idup; 1258 } 1259 1260 1261 // highlightEmotesImpl 1262 /++ 1263 Highlights Twitch emotes in the chat by tinting them a different colour, 1264 saving the results into a passed output range sink. 1265 1266 Params: 1267 sink = Output range to put the results into. 1268 line = Content line whose containing emotes should be highlit. 1269 emotes = The list of emotes and their positions as divined from the 1270 IRCv3 tags of an event. 1271 pre = Terminal foreground tint to colour the emotes with. 1272 post = Terminal foreground tint to reset to after colouring an emote. 1273 colourful = Whether or not emotes should be highlit in colours. 1274 brightTerminal = Whether or not the terminal has a bright background 1275 and colours should be adapted to suit. 1276 +/ 1277 version(Colours) 1278 version(TwitchSupport) 1279 void highlightEmotesImpl(Sink) 1280 (auto ref Sink sink, 1281 const string line, 1282 const string emotes, 1283 const TerminalForeground pre, 1284 const TerminalForeground post, 1285 const Flag!"colourful" colourful, 1286 const CoreSettings settings) 1287 if (isOutputRange!(Sink, char[])) 1288 { 1289 import std.algorithm.iteration : splitter, uniq; 1290 import std.algorithm.sorting : sort; 1291 import std.array : Appender; 1292 import std.conv : to; 1293 1294 static struct Highlight 1295 { 1296 string id; 1297 size_t start; 1298 size_t end; 1299 } 1300 1301 // max encountered emotes so far: 46 1302 // Severely pathological let's-crash-the-bot case: max possible ~161 emotes 1303 // That is a standard PRIVMSG line with ":) " repeated until 512 chars. 1304 //enum maxHighlights = 162; 1305 1306 static Appender!(Highlight[]) highlights; 1307 1308 scope(exit) 1309 { 1310 if (highlights.data.length) 1311 { 1312 highlights.clear(); 1313 } 1314 } 1315 1316 if (highlights.capacity == 0) 1317 { 1318 highlights.reserve(64); // guesstimate 1319 } 1320 1321 size_t pos; 1322 1323 foreach (/*const*/ emote; emotes.splitter('/')) 1324 { 1325 import lu.string : nom; 1326 1327 immutable emoteID = emote.nom(':'); 1328 1329 foreach (immutable location; emote.splitter(',')) 1330 { 1331 import std.string : indexOf; 1332 1333 immutable dashPos = location.indexOf('-'); 1334 immutable start = location[0..dashPos].to!size_t; 1335 immutable end = location[dashPos+1..$].to!size_t + 1; // inclusive 1336 1337 highlights.put(Highlight(emoteID, start, end)); 1338 } 1339 } 1340 1341 /+ 1342 We need to use uniq since sometimes there will be custom emotes for which 1343 there are already official ones. Example: 1344 1345 content: Hey Dist, what’s up? distPls distRoll 1346 emotes: emotesv2_1e80339255a84a4ebbd0129851b90aa0:21-27/emotesv2_744f13dfe4a345c5be4becdeb05343ee:29-36/distPls:21-27 1347 1348 The first and the last are duplicates. 1349 +/ 1350 auto sortedHighlights = highlights.data 1351 .dup 1352 .sort!((a, b) => (a.start < b.start)) 1353 .uniq!((a, b) => (a.start == b.start)); // && (a.end == b.end)); 1354 1355 // We need a dstring since we're slicing something that isn't necessarily ASCII 1356 // Without this highlights become offset a few characters depending on the text 1357 immutable dline = line.to!dstring; 1358 1359 foreach (const highlight; sortedHighlights) 1360 { 1361 import kameloso.terminal.colours.defs : ANSICodeType; 1362 import kameloso.terminal.colours : applyANSI, getColourByHash; 1363 1364 immutable colour = colourful ? 1365 getColourByHash(highlight.id, settings) : 1366 pre; 1367 1368 sink.put(dline[pos..highlight.start]); 1369 sink.applyANSI(colour, ANSICodeType.foreground); 1370 sink.put(dline[highlight.start..highlight.end]); 1371 sink.applyANSI(post, ANSICodeType.foreground); 1372 pos = highlight.end; 1373 } 1374 1375 // Add the remaining tail from after the last emote 1376 sink.put(dline[pos..$]); 1377 } 1378 1379 /// 1380 version(Colours) 1381 version(TwitchSupport) 1382 unittest 1383 { 1384 import std.array : Appender; 1385 1386 Appender!(char[]) sink; 1387 1388 CoreSettings brightSettings; 1389 CoreSettings darkSettings; 1390 brightSettings.brightTerminal = true; 1391 1392 { 1393 immutable emotes = "212612:14-22/75828:24-29"; 1394 immutable line = "Moody the god pownyFine pownyL"; 1395 sink.highlightEmotesImpl(line, emotes, TerminalForeground.white, 1396 TerminalForeground.default_, No.colourful, darkSettings); 1397 assert((sink.data == "Moody the god \033[97mpownyFine\033[39m \033[97mpownyL\033[39m"), sink.data); 1398 } 1399 { 1400 sink.clear(); 1401 immutable emotes = "25:41-45"; 1402 immutable line = "whoever plays nintendo switch whisper me Kappa"; 1403 sink.highlightEmotesImpl(line, emotes, TerminalForeground.white, 1404 TerminalForeground.default_, No.colourful, darkSettings); 1405 assert((sink.data == "whoever plays nintendo switch whisper me \033[97mKappa\033[39m"), sink.data); 1406 } 1407 { 1408 sink.clear(); 1409 immutable emotes = "877671:8-17,19-28,30-39"; 1410 immutable line = "NOOOOOO camillsCry camillsCry camillsCry"; 1411 sink.highlightEmotesImpl(line, emotes, TerminalForeground.white, 1412 TerminalForeground.default_, No.colourful, darkSettings); 1413 assert((sink.data == "NOOOOOO \033[97mcamillsCry\033[39m " ~ 1414 "\033[97mcamillsCry\033[39m \033[97mcamillsCry\033[39m"), sink.data); 1415 } 1416 { 1417 sink.clear(); 1418 immutable emotes = "822112:0-6,8-14,16-22"; 1419 immutable line = "FortOne FortOne FortOne"; 1420 sink.highlightEmotesImpl(line, emotes, TerminalForeground.white, 1421 TerminalForeground.default_, No.colourful, darkSettings); 1422 assert((sink.data == "\033[97mFortOne\033[39m \033[97mFortOne\033[39m " ~ 1423 "\033[97mFortOne\033[39m"), sink.data); 1424 } 1425 { 1426 sink.clear(); 1427 immutable emotes = "141844:17-24,26-33,35-42/141073:9-15"; 1428 immutable line = "@mugs123 cohhWow cohhBoop cohhBoop cohhBoop"; 1429 sink.highlightEmotesImpl(line, emotes, TerminalForeground.white, 1430 TerminalForeground.default_, No.colourful, darkSettings); 1431 assert((sink.data == "@mugs123 \033[97mcohhWow\033[39m \033[97mcohhBoop\033[39m " ~ 1432 "\033[97mcohhBoop\033[39m \033[97mcohhBoop\033[39m"), sink.data); 1433 } 1434 { 1435 sink.clear(); 1436 immutable emotes = "12345:81-91,93-103"; 1437 immutable line = "Link Amazon Prime to your Twitch account and get a " ~ 1438 "FREE SUBSCRIPTION every month courageHYPE courageHYPE " ~ 1439 "twitch.amazon.com/prime | Click subscribe now to check if a " ~ 1440 "free prime sub is available to use!"; 1441 immutable highlitLine = "Link Amazon Prime to your Twitch account and get a " ~ 1442 "FREE SUBSCRIPTION every month \033[97mcourageHYPE\033[39m \033[97mcourageHYPE\033[39m " ~ 1443 "twitch.amazon.com/prime | Click subscribe now to check if a " ~ 1444 "free prime sub is available to use!"; 1445 sink.highlightEmotesImpl(line, emotes, TerminalForeground.white, 1446 TerminalForeground.default_, No.colourful, brightSettings); 1447 assert((sink.data == highlitLine), sink.data); 1448 } 1449 { 1450 sink.clear(); 1451 immutable emotes = "25:32-36"; 1452 immutable line = "@kiwiskool but you’re a sub too Kappa"; 1453 sink.highlightEmotesImpl(line, emotes, TerminalForeground.white, 1454 TerminalForeground.default_, No.colourful, brightSettings); 1455 assert((sink.data == "@kiwiskool but you’re a sub too \033[97mKappa\033[39m"), sink.data); 1456 } 1457 { 1458 sink.clear(); 1459 immutable emotes = "425618:6-8,16-18/1:20-21"; 1460 immutable line = "高所恐怖症 LUL なにぬねの LUL :)"; 1461 sink.highlightEmotesImpl(line, emotes, TerminalForeground.white, 1462 TerminalForeground.default_, No.colourful, brightSettings); 1463 assert((sink.data == "高所恐怖症 \033[97mLUL\033[39m なにぬねの " ~ 1464 "\033[97mLUL\033[39m \033[97m:)\033[39m"), sink.data); 1465 } 1466 { 1467 sink.clear(); 1468 immutable emotes = "425618:6-8,16-18/1:20-21"; 1469 immutable line = "高所恐怖症 LUL なにぬねの LUL :)"; 1470 sink.highlightEmotesImpl(line, emotes, TerminalForeground.white, 1471 TerminalForeground.default_, Yes.colourful, brightSettings); 1472 assert((sink.data == "高所恐怖症 \033[38;5;171mLUL\033[39m なにぬねの " ~ 1473 "\033[38;5;171mLUL\033[39m \033[35m:)\033[39m"), sink.data); 1474 } 1475 { 1476 sink.clear(); 1477 immutable emotes = "212612:14-22/75828:24-29"; 1478 immutable line = "Moody the god pownyFine pownyL"; 1479 sink.highlightEmotesImpl(line, emotes, TerminalForeground.white, 1480 TerminalForeground.default_, Yes.colourful, brightSettings); 1481 assert((sink.data == "Moody the god \033[38;5;237mpownyFine\033[39m \033[38;5;159mpownyL\033[39m"), sink.data); 1482 } 1483 { 1484 sink.clear(); 1485 immutable emotes = "25:41-45"; 1486 immutable line = "whoever plays nintendo switch whisper me Kappa"; 1487 sink.highlightEmotesImpl(line, emotes, TerminalForeground.white, 1488 TerminalForeground.default_, Yes.colourful, brightSettings); 1489 assert((sink.data == "whoever plays nintendo switch whisper me \033[38;5;49mKappa\033[39m"), sink.data); 1490 } 1491 { 1492 sink.clear(); 1493 immutable emotes = "877671:8-17,19-28,30-39"; 1494 immutable line = "NOOOOOO camillsCry camillsCry camillsCry"; 1495 sink.highlightEmotesImpl(line, emotes, TerminalForeground.white, 1496 TerminalForeground.default_, Yes.colourful, brightSettings); 1497 assert((sink.data == "NOOOOOO \033[38;5;166mcamillsCry\033[39m " ~ 1498 "\033[38;5;166mcamillsCry\033[39m \033[38;5;166mcamillsCry\033[39m"), sink.data); 1499 } 1500 } 1501 1502 1503 // containsNickname 1504 /++ 1505 Searches a string for a substring that isn't surrounded by characters that 1506 can be part of a nickname. This can detect a nickname in a string without 1507 getting false positives from similar nicknames. 1508 1509 Tries to detect nicknames enclosed in terminal formatting. As such, call this 1510 *after* having translated IRC-to-terminal such with 1511 [kameloso.irccolours.mapEffects]. 1512 1513 Uses [std.string.indexOf|indexOf] internally with hopes of being more resilient to 1514 weird UTF-8. 1515 1516 Params: 1517 haystack = A string to search for the substring nickname. 1518 needle = The nickname substring to find in `haystack`. 1519 1520 Returns: 1521 True if `haystack` contains `needle` in such a way that it is guaranteed 1522 to not be a different nickname. 1523 +/ 1524 auto containsNickname(const string haystack, const string needle) pure nothrow @nogc 1525 in (needle.length, "Tried to determine whether an empty nickname was in a string") 1526 { 1527 import kameloso.terminal : TerminalToken; 1528 import dialect.common : isValidNicknameCharacter; 1529 import std.string : indexOf; 1530 1531 if ((haystack.length == needle.length) && (haystack == needle)) return true; 1532 1533 immutable pos = haystack.indexOf(needle); 1534 if (pos == -1) return false; 1535 1536 if (pos > 0) 1537 { 1538 bool match; 1539 1540 version(Colours) 1541 { 1542 if ((pos >= 4) && (haystack[pos-1] == 'm')) 1543 { 1544 import std.algorithm.comparison : min; 1545 import std.ascii : isDigit; 1546 1547 bool previousWasNumber; 1548 bool previousWasBracket; 1549 1550 foreach_reverse (immutable i, immutable c; haystack[pos-min(8, pos)..pos-1]) 1551 { 1552 if (c.isDigit) 1553 { 1554 if (previousWasBracket) return false; 1555 previousWasNumber = true; 1556 } 1557 else if (c == ';') 1558 { 1559 if (!previousWasNumber) return false; 1560 previousWasNumber = false; 1561 } 1562 else if (c == '[') 1563 { 1564 if (!previousWasNumber) return false; 1565 previousWasNumber = false; 1566 previousWasBracket = true; 1567 } 1568 else if (c == TerminalToken.format) 1569 { 1570 if (!previousWasBracket) return false; 1571 1572 // Seems valid, drop down 1573 match = true; 1574 break; 1575 } 1576 else 1577 { 1578 // Invalid character 1579 return false; 1580 } 1581 } 1582 } 1583 } 1584 1585 if (match) 1586 { 1587 // The above found a formatted nickname 1588 } 1589 else if (haystack[pos-1] == '@') 1590 { 1591 // "@kameloso" 1592 } 1593 else if (haystack[pos-1].isValidNicknameCharacter || 1594 (haystack[pos-1] == '.') || 1595 (haystack[pos-1] == '/')) 1596 { 1597 // URL or run-on word 1598 return false; 1599 } 1600 } 1601 1602 immutable end = pos + needle.length; 1603 1604 if (end > haystack.length) 1605 { 1606 return false; 1607 } 1608 else if (end == haystack.length) 1609 { 1610 return true; 1611 } 1612 1613 if (haystack[end] == TerminalToken.format) 1614 { 1615 // Run-on formatted word 1616 return true; 1617 } 1618 else 1619 { 1620 return !haystack[end].isValidNicknameCharacter; 1621 } 1622 } 1623 1624 /// 1625 unittest 1626 { 1627 assert("kameloso".containsNickname("kameloso")); 1628 assert(" kameloso ".containsNickname("kameloso")); 1629 assert(!"kam".containsNickname("kameloso")); 1630 assert(!"kameloso^".containsNickname("kameloso")); 1631 assert(!string.init.containsNickname("kameloso")); 1632 //assert(!"kameloso".containsNickname("")); // For now let this be false. 1633 assert("@kameloso".containsNickname("kameloso")); 1634 assert(!"www.kameloso.com".containsNickname("kameloso")); 1635 assert("kameloso.".containsNickname("kameloso")); 1636 assert("kameloso/".containsNickname("kameloso")); 1637 assert(!"/kameloso/".containsNickname("kameloso")); 1638 assert(!"kamelosoooo".containsNickname("kameloso")); 1639 assert(!"".containsNickname("kameloso")); 1640 1641 version(Colours) 1642 { 1643 assert("\033[1mkameloso".containsNickname("kameloso")); 1644 assert("\033[2;3mkameloso".containsNickname("kameloso")); 1645 assert("\033[12;34mkameloso".containsNickname("kameloso")); 1646 assert(!"\033[0m0mkameloso".containsNickname("kameloso")); 1647 assert(!"\033[kameloso".containsNickname("kameloso")); 1648 assert(!"\033[mkameloso".containsNickname("kameloso")); 1649 assert(!"\033[0kameloso".containsNickname("kameloso")); 1650 assert(!"\033[0mmkameloso".containsNickname("kameloso")); 1651 assert(!"\033[0;mkameloso".containsNickname("kameloso")); 1652 assert("\033[12mkameloso\033[1mjoe".containsNickname("kameloso")); 1653 assert(!"0mkameloso".containsNickname("kameloso")); 1654 } 1655 }