commit 608104382a5938be67992af7ef67cc30d078b512 from: lex0de date: Mon Oct 19 19:31:05 2026 UTC RSS output on all channels commit - ea5b31460b8101c592cf55d5c5dea682a225066b commit + 608104382a5938be67992af7ef67cc30d078b512 blob - 444f5b0b54673d4e101634dee1fa2f46c6434e9a blob + 78c3f159361a6a260009ea801412e7a3fe8fbd47 --- crybot.pl +++ crybot.pl @@ -69,7 +69,7 @@ my %config = ( ], ); -$config{channels} = [ uniq( $config{channel}, '#cryogenix', '#cryogenix' ) ]; +$config{channels} = [ channel_list( $config{channel}, '#cryogenix' ) ]; $config{silence} = uc $config{silence}; $config{feed_poll_interval} = ($config{feed_poll_interval} && $config{feed_poll_interval} =~ /^\d+$/) @@ -139,6 +139,12 @@ sub main { event_loop( \@connections, \%config ); } +# Normalize environment strings and hard-coded lists into distinct destinations. +sub channel_list { + return uniq( grep { length } map { split /[,\s]+/a } + grep { defined } @_ ); +} + sub build_server_configs { my ($config) = @_; my $supernets = { @@ -159,7 +165,8 @@ sub build_server_configs { sasl_pass => defined $ENV{CRYBOT_SUPERNETS_SASL_PASS} ? $ENV{CRYBOT_SUPERNETS_SASL_PASS} : $config->{sasl_pass}, - channels => [ uniq( $ENV{CRYBOT_SUPERNETS_CHANNEL} // '#supernets' ) ], + channels => [ channel_list( + $ENV{CRYBOT_SUPERNETS_CHANNEL} // '#supernets' ) ], }; my $primary = { key => 'libera', @@ -173,7 +180,8 @@ sub build_server_configs { pass => $config->{pass}, sasl_user => $config->{sasl_user}, sasl_pass => $config->{sasl_pass}, - channels => [ uniq( $config->{channel}, '#cryogenix' ) ], + channels => [ channel_list( $config->{channel}, + @{ $config->{channels} // ['#cryogenix'] } ) ], }; return map { blob - 33bfa26f84742fa9ae5d8a2cfa1f181a7eccaecf blob + b642141ce31d2c58708b430daa1b966bb3b3d6b7 --- t/feed_polling.t +++ t/feed_polling.t @@ -5,12 +5,21 @@ use v5.24; use strict; use warnings; +use File::Temp qw(tempdir); use FindBin qw($Bin); +use JSON::PP qw(decode_json); use Test::More; -my $script = "$Bin/../crybot.pl"; -my $loaded = do $script; -die "Unable to load $script: $@ $!" unless defined $loaded; +my $test_directory = tempdir( CLEANUP => 1 ); +{ + local %ENV = %ENV; + delete @ENV{ grep { /^CRYBOT_/ } keys %ENV }; + $ENV{CRYBOT_FEED_STATE} = "$test_directory/startup-state.json"; + local @ARGV = (); + my $script = "$Bin/../crybot.pl"; + my $loaded = do $script; + die "Unable to load $script: $@ $!" unless defined $loaded; +} sub feed_item { my ( $guid, $title ) = @_; @@ -115,4 +124,97 @@ subtest 'separate server configurations keep channels 'Supernets channel is isolated to its server' ); }; +subtest 'configured and hard-coded channel lists are retained per server' => sub { + local $ENV{CRYBOT_SUPERNETS_CHANNEL} = '#secondary, #other #SECONDARY'; + my @servers = build_server_configs({ + channel => '#configured,#second #third', + channels => [ '#hardcoded', '#CONFIGURED', undef, '', '#extra #third' ], + }); + is_deeply( $servers[0]{channels}, + [ '#configured', '#second', '#third', '#hardcoded', '#extra' ], + 'primary retains scalar settings and every hard-coded channel' ); + is_deeply( $servers[1]{channels}, [ '#secondary', '#other' ], + 'secondary splits and deduplicates its own channel setting' ); + + my @legacy = build_server_configs({ channel => '#custom' }); + is_deeply( $legacy[0]{channels}, [ '#custom', '#cryogenix' ], + 'legacy configuration without a channel array retains its default' ); +}; + +subtest 'every feed reaches every configured channel on its own connection' => sub { + local $ENV{CRYBOT_SUPERNETS_CHANNEL} = '#secondary, #shared #SECONDARY'; + my @servers = build_server_configs({ + channel => '#from-env, #second', + channels => [ '#from-env', '#hardcoded', '#shared' ], + }); + my @wire = ( '', '' ); + my @connections; + for my $index ( 0 .. 1 ) { + open my $sock, '>:raw', \$wire[$index] or die "open capture: $!"; + push @connections, { config => $servers[$index], sock => $sock }; + join_channels( $sock, $servers[$index] ); + } + my @feeds = map { { + key => $_, label => $_, url => "https://example.invalid/$_.rss", + } } qw(articles forums lobsters); + my $config = { + silence => 'NO', + feed_sources => \@feeds, + feed_state_file => "$test_directory/broadcast-state.json", + feed_state => { map { $_ => { last_guid => "$_-old" } } + qw(articles forums lobsters) }, + }; + my @fetched; + no warnings qw(once redefine); + local *main::http_get = sub { + my ($url) = @_; + push @fetched, $url; + my ($key) = $url =~ m{/([^/]+)\.rss\z}; + return "$key-new" + . "New $key" + . "https://example.invalid/$key-new" + . "$key-oldOld" + . ''; + }; + poll_feeds( \@connections, $config ); + is_deeply( \@fetched, [ map { $_->{url} } @feeds ], + 'each feed is fetched once for all networks and channels' ); + my @expected_channels = ( + [ '#from-env', '#second', '#hardcoded', '#shared' ], + [ '#secondary', '#shared' ], + ); + for my $index ( 0 .. 1 ) { + my @channels = @{ $expected_channels[$index] }; + my @expected = map { "JOIN $_" } @channels; + for my $feed (qw(articles forums lobsters)) { + push @expected, map { + "PRIVMSG $_ :[$feed] New $feed - " + . "https://example.invalid/$feed-new" + } @channels; + } + is_deeply( [ split /\r\n/, $wire[$index] ], \@expected, + "connection $index joins and announces once per configured channel" ); + } + my %expected_state = map { $_ => { last_guid => "$_-new" } } + qw(articles forums lobsters); + is_deeply( $config->{feed_state}, \%expected_state, + 'all feed cursors advance after broadcasting' ); + open my $state_file, '<:raw', $config->{feed_state_file} + or die "open state: $!"; + my $saved = do { local $/; <$state_file> }; + close $state_file or die "close state: $!"; + is_deeply( decode_json($saved), \%expected_state, 'cursors are persisted' ); + + my @before = @wire; + poll_feeds( \@connections, $config ); + is_deeply( \@wire, \@before, 'unchanged feeds are not broadcast again' ); + $config->{silence} = 'YES'; + $config->{feed_state}{articles}{last_guid} = 'articles-old'; + poll_feeds( \@connections, $config ); + is_deeply( \@wire, \@before, 'silence still suppresses every destination' ); + is( $config->{feed_state}{articles}{last_guid}, 'articles-new', + 'silenced updates still advance the cursor' ); + close $_->{sock} for @connections; +}; + done_testing;