commit ea5b31460b8101c592cf55d5c5dea682a225066b from: lex0de date: Mon Oct 19 19:25:07 2026 UTC fortune commit - 3d657e5fb6ec4c05daf6972bc7d8693b5072a3d1 commit + ea5b31460b8101c592cf55d5c5dea682a225066b blob - 88ad8b734d345544e842a52e086a9ab0e012b714 blob + b5eaf3c34dbc61afd17c7d084a13050b3166b515 --- commands.txt +++ commands.txt @@ -3,6 +3,7 @@ Crybot IRC Commands !axfr !banner !confession +!fortune [subject or question] !help !ipinfo !md5 blob - 0eb676381591f32b75aac415b1e2f7b4a8572427 blob + 444f5b0b54673d4e101634dee1fa2f46c6434e9a --- crybot.pl +++ crybot.pl @@ -1,4 +1,6 @@ #!/usr/bin/env perl +# Crybot - Developed by lex0de (lex0de@tuta.com) +# cryobot/crybot.pl use v5.24; use strict; @@ -21,7 +23,7 @@ use HTTP::Tiny; use JSON::PP qw(decode_json encode_json); use URI::Escape qw(uri_escape); use MIME::Base64 qw(encode_base64); -use Encode qw(decode); +use Encode qw(decode encode); use Time::HiRes qw(time); use File::Spec; use FindBin qw($Bin); @@ -95,6 +97,7 @@ my %COMMANDS = ( ud => \&cmd_ud, sicksearch => \&cmd_sicksearch, confession => \&cmd_confession, + fortune => \&cmd_fortune, sickjoke => \&cmd_sickjoke, ns => \&cmd_ns, ipinfo => \&cmd_ipinfo, @@ -815,6 +818,198 @@ sub cmd_ipinfo { say "[ $target ] <$nick> [checkip] $host | $cc:$org"; } +sub cmd_fortune { + my ( $sock, $config, $nick, $target, $args, $is_private ) = @_; + my $reading = fortune_reading( cast_fortune() ); + my $out = response_target( $nick, $target, $is_private ); + send_fortune( $sock, $out, fortune_messages( $nick, $args, $reading ) ); +} + +# Return six totals, bottom first. An injected RNG accepts an exclusive limit. +sub cast_fortune { + my ($rng) = @_; + $rng //= sub { rand $_[0] }; + my @lines; + for ( 1 .. 6 ) { + my $total = 0; + $total += 2 + int $rng->(2) for 1 .. 3; + push @lines, $total; + } + return \@lines; +} + +# Patterns run bottom to top, with 1 = yang. Advice is original, brief prose. +# King Wen pattern cross-check: github.com/zzkt/i-ching, i-ching.el. +# Traditional themes: sacred-texts.com/ich/ictoc.htm (Legge translation). +sub fortune_hexagram { + my ($pattern) = @_; + state $hexagrams = do { + my %table; + my $number = 0; + my $data = <<'HEXAGRAMS'; +111111|The Creative|Take initiative, then sustain your effort. +000000|The Receptive|Listen first; patient support opens the way. +100010|Difficulty at the Beginning|Start small and gather help through confusion. +010001|Youthful Folly|Admit what you do not know and seek guidance. +111010|Waiting|Prepare carefully; let the right moment arrive. +010111|Conflict|Seek common ground before pressing your case. +010000|The Army|Bring discipline and a shared purpose to the task. +000010|Holding Together|Choose reliable allies and commit sincerely. +111011|Small Taming|Small improvements matter more than force today. +110111|Treading|Advance with care and respect the risks. +111000|Peace|Use this opening to build lasting cooperation. +000111|Standstill|Protect your principles while progress is blocked. +101111|Fellowship|Find a shared aim beyond personal differences. +111101|Great Possession|Use your resources generously and responsibly. +001000|Modesty|Let quiet competence carry more weight than pride. +000100|Enthusiasm|Turn inspiration into a practical shared effort. +100110|Following|Adapt willingly without abandoning your values. +011001|Repair|Address the roots of what has gone wrong. +110000|Approach|Meet new opportunities with care and foresight. +000011|Contemplation|Step back and observe before choosing a direction. +100101|Biting Through|Deal clearly and fairly with the real obstacle. +101001|Grace|Choose a simple presentation with substance behind it. +000001|Splitting Apart|Release what is failing and protect your foundations. +100000|Return|Begin again gently; a small renewal can grow. +100111|Innocence|Act honestly without forcing a clever outcome. +111001|Great Taming|Build knowledge and restraint before using your power. +100001|Nourishment|Notice what you feed your mind and offer others. +011110|Great Preponderance|Ease the strain before taking on more weight. +010010|The Abysmal Water|Keep your footing and take difficulties step by step. +101101|The Clinging Fire|Seek clarity and attend to what sustains it. +001110|Influence|Let openness and sincerity invite a response. +011100|Duration|Steady habits can carry you through changing moods. +001111|Retreat|A timely step back can preserve your strength. +111100|Great Power|Use strength with restraint rather than pressure. +000101|Progress|Make your work visible and advance with integrity. +101000|Darkening of the Light|Guard your inner clarity in difficult surroundings. +101011|The Family|Care for close relationships and shared responsibilities. +110101|Opposition|Respect differences and cooperate on smaller aims. +001010|Obstruction|Pause, seek support, and consider another route. +010100|Deliverance|Resolve what remains, then let old tension go. +110001|Decrease|Simplify willingly and spend your energy wisely. +100011|Increase|Use growing resources to help something worthwhile. +111110|Breakthrough|Speak plainly and act firmly without aggression. +011111|Coming to Meet|Notice a powerful influence before inviting it closer. +000110|Gathering Together|Bring people and resources around a clear purpose. +011000|Pushing Upward|Patient, modest steps can lead to real growth. +010110|Oppression|Conserve your strength and stay true under pressure. +011010|The Well|Renew the shared sources of support you depend on. +101110|Revolution|Change what is outdated when the time is right. +011101|The Cauldron|Transform what you have into lasting nourishment. +100100|The Arousing Thunder|Let surprise wake you without losing your balance. +001001|Keeping Still|Quiet your reactions before deciding what comes next. +001011|Development|Allow trust and progress to mature gradually. +110100|The Marrying Maiden|Know your position and avoid forcing a commitment. +101100|Abundance|Use a fruitful moment well; conditions will change. +001101|The Wanderer|Travel lightly and respect unfamiliar surroundings. +011011|The Gentle Wind|Patient, consistent influence can reach deep roots. +110110|The Joyous Lake|Share honest encouragement without empty promises. +010011|Dispersion|Loosen divisions and reconnect around shared values. +110010|Limitation|Set workable boundaries that protect your energy. +110011|Inner Truth|Let sincerity guide both your words and your listening. +001100|Small Preponderance|Attend to small duties rather than grand gestures. +101010|After Completion|Maintain what works; success still needs attention. +010101|Before Completion|Proceed carefully; the final crossing is unfinished. +HEXAGRAMS + for my $row ( split /\n/, $data ) { + my ( $bits, $name, $advice ) = split /\|/, $row; + $table{$bits} = { + number => ++$number, + name => $name, + advice => $advice, + }; + } + \%table; + }; + return unless defined $pattern && $pattern =~ /\A[01]{6}\z/; + return $hexagrams->{$pattern}; +} + +# Return the cast, changing positions (1 = bottom), and both hexagram records. +sub fortune_reading { + my ($lines) = @_; + die "Invalid fortune cast\n" unless ref $lines eq 'ARRAY' + && @{$lines} == 6 + && !grep { !defined $_ || !/\A[6-9]\z/ } @{$lines}; + my @present = map { $_ % 2 } @{$lines}; + my @future = map { $_ == 6 || $_ == 9 ? 1 - $_ % 2 : $_ % 2 } + @{$lines}; + my @changing = grep { $lines->[$_ - 1] == 6 || $lines->[$_ - 1] == 9 } + 1 .. 6; + return { + lines => [ @{$lines} ], + present => fortune_hexagram( join '', @present ), + future => fortune_hexagram( join '', @future ), + changing => \@changing, + }; +} + +# Decode IRC bytes once and return a bounded, printable label. +sub fortune_text { + my ( $text, $limit ) = @_; + $text //= ''; + $text = decode( 'UTF-8', $text, Encode::FB_DEFAULT ) + unless utf8::is_utf8($text); + $text =~ s/[\p{Cc}\p{Cf}]/ /g; + $text =~ s/\s+/ /g; + $text =~ s/^\s+|\s+$//g; + return substr $text, 0, $limit; +} + +# Return ten decoded messages; cast arrays are bottom first, drawings top first. +sub fortune_messages { + my ( $nick, $subject, $reading ) = @_; + $nick = fortune_text( $nick, 80 ); + $subject = fortune_text( $subject, 80 ); + $subject = 'your current situation' unless length $subject; + my ( $present, $future ) = @{$reading}{qw(present future)}; + my $totals = join ',', @{ $reading->{lines} }; + my $changes = join( ',', @{ $reading->{changing} } ) || 'none'; + my @messages = ( + "$nick: Three coins for $subject. " + . "Lines 1-6: $totals; changing: $changes.", + "Present #$present->{number} $present->{name} | " + . "Future #$future->{number} $future->{name} (* = changing)", + ); + for my $index ( reverse 0 .. 5 ) { + my $total = $reading->{lines}[$index]; + my $is_changing = $total == 6 || $total == 9; + my $yang = $total % 2; + my $next_yang = $is_changing ? !$yang : $yang; + push @messages, sprintf '%d %s%s | %s', $index + 1, + $yang ? '━━━━━━━' : '━━━ ━━━', $is_changing ? '*' : ' ', + $next_yang ? '━━━━━━━' : '━━━ ━━━'; + } + push @messages, "Present: $present->{advice}"; + push @messages, @{ $reading->{changing} } + ? "Possible direction: $future->{advice}" + : "Future: The outlook remains steady. $future->{advice}"; + return \@messages; +} + +# Send UTF-8 bytes through the existing raw writer, within the IRC frame limit. +sub send_fortune { + my ( $sock, $target, $messages ) = @_; + return unless defined $target && length $target; + return if $target =~ /[\x00-\x20\x7F,]/; + $target = encode( 'UTF-8', $target ) if utf8::is_utf8($target); + my $prefix = "PRIVMSG $target :"; + my $budget = 512 - length($prefix) - 2; + return if $budget < 1; + for my $message ( @{$messages} ) { + my $text = $message; + $text =~ s/[\p{Cc}\p{Cf}]/ /g; + my $bytes = encode( 'UTF-8', $text ); + if ( length($bytes) > $budget ) { + my $clipped = substr $bytes, 0, $budget; + $bytes = encode( 'UTF-8', + decode( 'UTF-8', $clipped, Encode::FB_QUIET ) ); + } + irc_print( $sock, $prefix . $bytes ); + } +} + sub cmd_help { my ( $sock, $config, $nick, $target, $args, $is_private ) = @_; my @lines = ( @@ -828,6 +1023,7 @@ sub cmd_help { '- !ud - UrbanDictionary', '- !sickjoke - Sickipedia Joke', '- !confession - Confession', + '- !fortune [subject or question] - I Ching reading', ); my $out = response_target( $nick, $target, $is_private ); @@ -1022,7 +1218,8 @@ sub check_ip { sub parse_irc_line { my ($line) = @_; my $prefix = ''; - if ( $line =~ s/^:([^\s]+)\s+// ) { + # IRC separators are ASCII; UTF-8 continuation bytes are not whitespace. + if ( $line =~ s/^:([^\s]+)\s+//a ) { $prefix = $1; } @@ -1032,7 +1229,7 @@ sub parse_irc_line { push @params, $1; last; } - $line =~ s/^(\S+)\s*//; + $line =~ s/^(\S+)\s*//a; push @params, $1; } blob - /dev/null blob + b1c33e04d1626ee6db685d2221e5476141e0acb6 (mode 644) --- /dev/null +++ t/fortune.t @@ -0,0 +1,273 @@ +# Crybot - Developed by lex0de (lex0de@tuta.com) +# cryobot/t/fortune.t + +use v5.24; +use strict; +use warnings; +use utf8; + +use Encode qw(decode encode); +use File::Temp qw(tempdir); +use FindBin qw($Bin); +use Test::More; + +my $directory = tempdir( CLEANUP => 1 ); +{ + local %ENV = %ENV; + delete @ENV{ grep { /^CRYBOT_/ } keys %ENV }; + $ENV{CRYBOT_FEED_STATE} = "$directory/state.json"; + local @ARGV = (); + my $script = "$Bin/../crybot.pl"; + my $loaded = do $script; + die "Unable to load $script: $@ $!" unless defined $loaded; +} + +# Exercise the IRC parser and dispatcher with a raw in-memory socket substitute. +sub capture_command { + my ( $target, $message, $config, $nick ) = @_; + $nick //= 'reader'; + my $wire = ''; + open my $sock, '>:raw', \$wire or die "open capture: $!"; + handle_line( $sock, $config, + ":$nick!user\@example.invalid PRIVMSG $target :$message" ); + close $sock or die "close capture: $!"; + return $wire; +} + +sub decoded_frames { + my ($wire) = @_; + return [ split /\r\n/, decode( 'UTF-8', $wire, Encode::FB_CROAK ) ]; +} + +subtest 'all three-coin outcomes have the expected exact odds' => sub { + my %frequency; + for my $outcome ( 0 .. 7 ) { + my @coins = map { ( $outcome >> $_ ) & 1 } 0 .. 2; + my $expected = 6 + $coins[0] + $coins[1] + $coins[2]; + my $calls = 0; + my @limits; + my $lines = cast_fortune( sub { + my ($limit) = @_; + push @limits, $limit; + return $coins[$calls++ % 3]; + } ); + is_deeply( \@limits, [ (2) x 18 ], 'each coin has two sides' ); + is( $calls, 18, 'six independent triples are cast' ); + is_deeply( $lines, [ ($expected) x 6 ], 'coin values sum correctly' ); + $frequency{ $lines->[0] }++; + } + is_deeply( \%frequency, { 6 => 1, 7 => 3, 8 => 3, 9 => 1 }, + 'coin odds are 1:3:3:1' ); + my $lines = cast_fortune(); + is( scalar @{$lines}, 6, 'default RNG produces six lines' ); + ok( !grep( { $_ < 6 || $_ > 9 } @{$lines} ), 'default totals are valid' ); +}; + +subtest 'casts are built bottom first and only old lines change' => sub { + my @coins = ( 0, 0, 0, 1, 0, 0, 1, 1, 0, + 1, 1, 1, 1, 0, 0, 1, 1, 0 ); + my $lines = cast_fortune( sub { shift @coins } ); + is_deeply( $lines, [ 6, 7, 8, 9, 7, 8 ], 'bottom-first cast retained' ); + my $reading = fortune_reading($lines); + is_deeply( $reading->{changing}, [ 1, 4 ], 'changing positions start at bottom' ); + is( $reading->{present}{number}, 47, 'present is lake over water' ); + is( $reading->{future}{number}, 60, 'future is water over lake' ); + is_deeply( $reading->{lines}, $lines, 'cast is retained for display' ); + is_deeply( $lines, [ 6, 7, 8, 9, 7, 8 ], 'input was not changed' ); + for my $total ( 7, 8 ) { + my $steady = fortune_reading( [ ($total) x 6 ] ); + is_deeply( $steady->{changing}, [], 'young lines are stable' ); + is_deeply( $steady->{present}, $steady->{future}, 'steady result matches' ); + } + my $all_yin = fortune_reading( [ (6) x 6 ] ); + is( $all_yin->{present}{number}, 2, 'old yin starts as Earth' ); + is( $all_yin->{future}{number}, 1, 'all old yin changes to Heaven' ); + is_deeply( $all_yin->{changing}, [ 1 .. 6 ], 'all six positions change' ); + my $all_yang = fortune_reading( [ (9) x 6 ] ); + is( $all_yang->{present}{number}, 1, 'old yang starts as Heaven' ); + is( $all_yang->{future}{number}, 2, 'all old yang changes to Earth' ); +}; + +subtest 'invalid internal casts are rejected' => sub { + for my $input ( undef, {}, [], [ 7, 7, 7, 7, 7 ], + [ 7, 7, 7, 7, 7, 10 ], [ 7, 7, 7, 7, 7, undef ] ) { + my $ok = eval { fortune_reading($input); 1 }; + ok( !$ok, 'invalid cast rejected' ); + like( $@, qr/Invalid fortune cast/, 'failure describes the cast' ); + } +}; + +subtest 'all 64 patterns agree with the traditional trigram matrix' => sub { + # Rows are lower trigrams; columns upper: heaven, lake, fire, thunder, + # wind, water, mountain, earth. Independent matrix checks bit orientation. + my @trigrams = qw(111 110 101 100 011 010 001 000); + my @matrix = ( + [ 1, 43, 14, 34, 9, 5, 26, 11 ], + [ 10, 58, 38, 54, 61, 60, 41, 19 ], + [ 13, 49, 30, 55, 37, 63, 22, 36 ], + [ 25, 17, 21, 51, 42, 3, 27, 24 ], + [ 44, 28, 50, 32, 57, 48, 18, 46 ], + [ 6, 47, 64, 40, 59, 29, 4, 7 ], + [ 33, 31, 56, 62, 53, 39, 52, 15 ], + [ 12, 45, 35, 16, 20, 8, 23, 2 ], + ); + my %seen; + for my $lower ( 0 .. 7 ) { + for my $upper ( 0 .. 7 ) { + my $pattern = $trigrams[$lower] . $trigrams[$upper]; + my $hexagram = fortune_hexagram($pattern); + is( $hexagram->{number}, $matrix[$lower][$upper], + "correct King Wen number for $pattern" ); + ok( length $hexagram->{name}, 'name exists' ); + ok( length $hexagram->{advice}, 'interpretation exists' ); + $seen{ $hexagram->{number} }++; + } + } + is_deeply( [ sort { $a <=> $b } keys %seen ], [ 1 .. 64 ], + 'all 64 King Wen numbers occur once' ); + for my $pattern ( undef, '', '01010', '0101010', '01010x', "010101\n" ) { + ok( !defined fortune_hexagram($pattern), 'invalid pattern rejected' ); + } +}; + +subtest 'ten messages draw matching diagrams from top to bottom' => sub { + my $reading = fortune_reading( [ 6, 7, 8, 9, 7, 8 ] ); + my $messages = fortune_messages( 'reader', 'a new job', $reading ); + is( scalar @{$messages}, 10, 'reply has a fixed ten-message size' ); + like( $messages->[0], qr/reader: Three coins for a new job\./, + 'reading includes nick and subject' ); + like( $messages->[0], qr/Lines 1-6: 6,7,8,9,7,8; changing: 1,4\./, + 'cast and changing positions are visible' ); + like( $messages->[1], qr/Present #47 Oppression \| Future #60 Limitation/, + 'columns identify both hexagrams' ); + is_deeply( [ @{$messages}[ 2 .. 7 ] ], [ + '6 ━━━ ━━━ | ━━━ ━━━', + '5 ━━━━━━━ | ━━━━━━━', + '4 ━━━━━━━* | ━━━ ━━━', + '3 ━━━ ━━━ | ━━━ ━━━', + '2 ━━━━━━━ | ━━━━━━━', + '1 ━━━ ━━━* | ━━━━━━━', + ], 'solid, broken and changing lines are correctly drawn' ); + is( $messages->[8], 'Present: ' . $reading->{present}{advice}, + 'present interpretation matches the result' ); + is( $messages->[9], 'Possible direction: ' . $reading->{future}{advice}, + 'future interpretation matches the result' ); +}; + +subtest 'unchanging fortunes show identical diagrams and steady outlook' => sub { + my $messages = fortune_messages( 'reader', '', + fortune_reading( [ (7) x 6 ] ) ); + like( $messages->[0], qr/your current situation.*changing: none/, + 'missing subject defaults to a general reading' ); + for my $index ( 0 .. 5 ) { + is( $messages->[$index + 2], + (6 - $index) . ' ━━━━━━━ | ━━━━━━━', 'both drawings match' ); + } + like( $messages->[9], qr/outlook remains steady/, 'no invented transformation' ); +}; + +subtest 'dispatch replies only to the requesting channel and connection' => sub { + no warnings 'redefine'; + local *main::cast_fortune = sub { [ 6, 7, 8, 9, 7, 8 ] }; + my $primary = { nick => 'GCHQ', silence => 'YES', channels => ['#primary'] }; + my $secondary = { nick => 'OtherBot', silence => 'NO', channels => ['#other'] }; + my $first = decoded_frames( capture_command( '#invited', '!fortune', $primary ) ); + my $second = decoded_frames( capture_command( + '#other', '!FORTUNE should I move?', $secondary ) ); + is( scalar @{$first}, 10, 'explicit command works even when silenced' ); + is( scalar @{$second}, 10, 'case-insensitive command works on second server' ); + ok( !grep( { !/^PRIVMSG #invited :/ } @{$first} ), + 'all first-connection replies go to the originating channel' ); + ok( !grep( { !/^PRIVMSG #other :/ } @{$second} ), + 'all second-connection replies stay on that channel' ); + like( $second->[0], qr/should I move\?/, 'optional sentence is preserved' ); + my $private = decoded_frames( capture_command( 'GCHQ', '!fortune work', $primary ) ); + is( scalar @{$private}, 10, 'private command is supported' ); + ok( !grep( { !/^PRIVMSG reader :/ } @{$private} ), + 'private replies go to the requesting nick' ); + is( capture_command( '#invited', '!fortuneunknown', $primary ), '', + 'other command names are not accidentally matched' ); +}; + +subtest 'subjects are bounded and controls and malformed UTF-8 are handled' => sub { + is( fortune_text( " \x01work\r\n\0\t plans\x7F ", 80 ), + 'work plans', 'controls and whitespace are normalized' ); + is( fortune_text( encode( 'UTF-8', '你好 café' ), 80 ), + '你好 café', 'raw UTF-8 is decoded once' ); + is( fortune_text( '你好 café', 80 ), '你好 café', 'decoded text stays intact' ); + is( fortune_text( "bad\xFFtopic", 80 ), "bad\x{FFFD}topic", + 'invalid bytes become replacement characters' ); + is( fortune_text( '界' x 200, 80 ), '界' x 80, + 'subject limit counts characters' ); + my $messages = fortune_messages( "reader\0injected", "\x01\r\n", + fortune_reading( [ (7) x 6 ] ) ); + like( $messages->[0], qr/reader injected:.*your current situation/, + 'empty sanitized subjects default and nick controls are removed' ); + my $zero = fortune_messages( 'reader', '0', + fortune_reading( [ (7) x 6 ] ) ); + like( $zero->[0], qr/Three coins for 0\./, 'zero is a valid subject' ); +}; + +subtest 'Unicode output is valid UTF-8 bytes on the wire' => sub { + no warnings 'redefine'; + local *main::cast_fortune = sub { [ (6) x 6 ] }; + my $config = { nick => 'GCHQ', silence => 'NO' }; + my $target = encode( 'UTF-8', '#café读者' ); + my $nick = encode( 'UTF-8', '读者' ); + my $question = encode( 'UTF-8', '!fortune 我的未来' ); + my $wire = capture_command( $target, $question, $config, $nick ); + ok( !utf8::is_utf8($wire), 'socket capture contains bytes' ); + my $messages = decoded_frames($wire); + is( scalar @{$messages}, 10, 'all Unicode messages round-trip' ); + like( $messages->[0], qr/^PRIVMSG #café读者 :读者:.*我的未来/, + 'target, nick and subject retain Unicode' ); + like( $messages->[2], qr/━━━ ━━━\* \| ━━━━━━━/, + 'drawing is encoded explicitly' ); +}; + +subtest 'frames stay bounded and cannot inject extra IRC lines' => sub { + my $wire = ''; + open my $sock, '>:raw', \$wire or die "open capture: $!"; + my $target = '#' . ('a' x 300); + send_fortune( $sock, $target, + [ ('界' x 1_000), "line\r\nPRIVMSG #other :bad\0\x01" ] ); + my @frames = split /\r\n/, $wire; + is( scalar @frames, 2, 'controls cannot create extra frames' ); + for my $frame (@frames) { + cmp_ok( length($frame) + 2, '<=', 512, 'frame including CRLF fits' ); + my $copy = $frame; + my $text = eval { decode( 'UTF-8', $copy, Encode::FB_CROAK ) }; + ok( defined $text && !$@, 'truncation retains complete UTF-8 characters' ); + unlike( $frame, qr/[\x00-\x1F\x7F]/, 'frame contains no control bytes' ); + } + like( $wire, qr/\r\n\z/, 'wire frames end in CRLF' ); + my $before = $wire; + for my $bad ( undef, '', "#bad\r\nJOIN #other", "#bad\0", '#a,#b', + '#' . ('a' x 510) ) { + send_fortune( $sock, $bad, ['never sent'] ); + } + is( $wire, $before, 'invalid or oversized targets produce no output' ); + close $sock or die "close capture: $!"; + + no warnings 'redefine'; + local *main::cast_fortune = sub { [ (7) x 6 ] }; + my $config = { nick => 'GCHQ', silence => 'NO' }; + my $long_wire = capture_command( '#normal', + encode( 'UTF-8', '!fortune ' . ('界' x 2_000) ), $config ); + my $messages = decoded_frames($long_wire); + my $displayed_subject = '界' x 80; + like( $messages->[0], qr/for \Q$displayed_subject\E\. Lines/, + 'dispatched long subject is capped at 80 characters' ); + for my $frame ( split /\r\n/, $long_wire ) { + cmp_ok( length($frame) + 2, '<=', 512, 'each reply fits the wire limit' ); + } +}; + +subtest 'help advertises the optional subject' => sub { + my $messages = decoded_frames( capture_command( '#channel', '!help', + { nick => 'GCHQ', silence => 'NO' } ) ); + ok( scalar grep( /!fortune \[subject or question\]/, @{$messages} ), + 'command appears in bot help' ); +}; + +done_testing;