Commit Diff


commit - 3d657e5fb6ec4c05daf6972bc7d8693b5072a3d1
commit + ea5b31460b8101c592cf55d5c5dea682a225066b
blob - 88ad8b734d345544e842a52e086a9ab0e012b714
blob + b5eaf3c34dbc61afd17c7d084a13050b3166b515
--- commands.txt
+++ commands.txt
@@ -3,6 +3,7 @@ Crybot IRC Commands
 !axfr <domain> <nameserver>
 !banner <host> <port>
 !confession
+!fortune [subject or question]
 !help
 !ipinfo <IP|host>
 !md5 <string>
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 <word>             - 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;