commit - ea5b31460b8101c592cf55d5c5dea682a225066b
commit + 608104382a5938be67992af7ef67cc30d078b512
blob - 444f5b0b54673d4e101634dee1fa2f46c6434e9a
blob + 78c3f159361a6a260009ea801412e7a3fe8fbd47
--- crybot.pl
+++ crybot.pl
],
);
-$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+$/)
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 = {
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',
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
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 ) = @_;
'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;