Commit Diff


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 "<rss><channel><item><guid>$key-new</guid>"
+			. "<title>New $key</title>"
+			. "<link>https://example.invalid/$key-new</link></item>"
+			. "<item><guid>$key-old</guid><title>Old</title></item>"
+			. '</channel></rss>';
+	};
+	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;