Commit Diff


commit - d38bceff4a0824695b93b2bebc22a9e73c6b8fd6
commit + a8f1ec70b68583ee34c276564cb53bea55e0a32b
blob - 316d1678ba5d5811bcadd191187443b5b8f12c5c
blob + 24496ef559b4f745d5bafcfb6ab50c4f9678f968
--- crybot.pl
+++ crybot.pl
@@ -9,6 +9,7 @@ use autodie qw(:socket :io);
 
 use Socket qw(AF_INET PF_INET SOCK_STREAM inet_aton inet_ntoa sockaddr_in);
 use IO::Select;
+use List::Util qw(first);
 use IO::Socket::SSL;
 use Digest::MD5 qw(md5_hex);
 use IP::Country::Fast;
@@ -79,6 +80,7 @@ if ( $config{feed_state_file} !~ m{^/} ) {
     $config{feed_state_file} = File::Spec->catfile( $Bin, $config{feed_state_file} );
 }
 $config{feed_state} = load_feed_state( $config{feed_state_file} );
+$config{servers} = [ build_server_configs( \%config ) ];
 
 $ua->agent('Cryogenix IRC Bot');
 $ua->timeout(5);
@@ -102,13 +104,82 @@ my %COMMANDS = (
 main() unless caller;
 
 sub main {
-    my $sock = connect_to_server(\%config);
-    register_with_server($sock, \%config);
-    identify_services($sock, \%config);
-    join_channels($sock, \%config);
-    event_loop($sock, \%config);
+    my @connections;
+
+    for my $server ( @{ $config{servers} } ) {
+        my $sock = eval { connect_to_server($server) };
+        if ( !$sock ) {
+            my $error = $@ || 'connection failed';
+            warn "IRC connection to $server->{server} failed: $error";
+            die $error if $server->{required};
+            next;
+        }
+
+        my $registered = eval {
+            register_with_server( $sock, $server );
+            identify_services( $sock, $server );
+            join_channels( $sock, $server );
+            1;
+        };
+        if ( !$registered ) {
+            my $error = $@ || 'registration failed';
+            warn "IRC registration on $server->{server} failed: $error";
+            close $sock;
+            die $error if $server->{required};
+            next;
+        }
+
+        push @connections, { config => $server, sock => $sock };
+    }
+
+    die "No IRC connections available\n" unless @connections;
+    event_loop( \@connections, \%config );
 }
 
+sub build_server_configs {
+    my ($config) = @_;
+    my $supernets = {
+        key        => 'supernets',
+        required   => 0,
+        server     => $ENV{CRYBOT_SUPERNETS_SERVER} // 'irc.supernets.org',
+        port       => $ENV{CRYBOT_SUPERNETS_PORT} // 6697,
+        nick       => $ENV{CRYBOT_SUPERNETS_NICK} // $config->{nick},
+        altnick    => $ENV{CRYBOT_SUPERNETS_ALTNICK} // $config->{altnick},
+        login      => $ENV{CRYBOT_SUPERNETS_LOGIN} // $config->{login},
+        version    => $ENV{CRYBOT_SUPERNETS_VERSION} // $config->{version},
+        pass       => defined $ENV{CRYBOT_SUPERNETS_PASSWORD}
+          ? $ENV{CRYBOT_SUPERNETS_PASSWORD}
+          : $config->{pass},
+        sasl_user  => defined $ENV{CRYBOT_SUPERNETS_SASL_USER}
+          ? $ENV{CRYBOT_SUPERNETS_SASL_USER}
+          : $config->{sasl_user},
+        sasl_pass  => defined $ENV{CRYBOT_SUPERNETS_SASL_PASS}
+          ? $ENV{CRYBOT_SUPERNETS_SASL_PASS}
+          : $config->{sasl_pass},
+        channels   => [ uniq( $ENV{CRYBOT_SUPERNETS_CHANNEL} // '#supernets' ) ],
+    };
+    my $primary = {
+        key        => 'libera',
+        required   => 1,
+        server     => $config->{server},
+        port       => $config->{port},
+        nick       => $config->{nick},
+        altnick    => $config->{altnick},
+        login      => $config->{login},
+        version    => $config->{version},
+        pass       => $config->{pass},
+        sasl_user  => $config->{sasl_user},
+        sasl_pass  => $config->{sasl_pass},
+        channels   => [ uniq( $config->{channel}, '#cryogenix' ) ],
+    };
+
+    return map {
+        my %server = %{$config};
+        @server{ keys %{$_} } = values %{$_};
+        \%server;
+    } ( $primary, $supernets );
+}
+
 sub connect_to_server {
     my ($config) = @_;
     IO::Socket::SSL->new(
@@ -252,8 +323,8 @@ sub join_channels {
 }
 
 sub event_loop {
-    my ( $sock, $config ) = @_;
-    my $selector = IO::Select->new($sock);
+    my ( $connections, $config ) = @_;
+    my $selector = IO::Select->new( map { $_->{sock} } @{$connections} );
     my $have_feeds = @{ $config->{feed_sources} // [] } ? 1 : 0;
     my $next_poll  = time;
 
@@ -261,7 +332,7 @@ sub event_loop {
         my $now = time;
 
         if ( $have_feeds && $now >= $next_poll ) {
-            eval { poll_feeds( $sock, $config ); };
+            eval { poll_feeds( $connections, $config ); };
             warn "Feed poll failed: $@" if $@;
             $next_poll = $now + $config->{feed_poll_interval};
         }
@@ -281,14 +352,33 @@ sub event_loop {
         }
 
         if (@ready) {
-            my $raw = <$sock>;
-            last unless defined $raw;
-            chomp $raw;
-            $raw =~ s/\r$//;
-            debug_log( $config, "<< $raw" );
-            handle_line( $sock, $config, $raw );
+            for my $sock (@ready) {
+                my $fd = fileno $sock;
+                my $connection = first { fileno( $_->{sock} ) == $fd } @{$connections};
+                next unless $connection;
+
+                my $raw = <$sock>;
+                if ( !defined $raw ) {
+                    $selector->remove($sock);
+                    @{$connections} = grep { fileno( $_->{sock} ) != $fd } @{$connections};
+                    close $sock;
+                    next;
+                }
+
+                chomp $raw;
+                $raw =~ s/\r$//;
+                debug_log( $connection->{config}, "<< $raw" );
+                eval {
+                    handle_line( $sock, $connection->{config}, $raw );
+                    1;
+                } or warn "IRC input failed on $connection->{config}{server}: $@";
+            }
         }
 
+        if ( !@{$connections} ) {
+            die "All IRC connections closed\n";
+        }
+
         if ( !@ready && !$have_feeds ) {
             next;
         }
@@ -393,7 +483,7 @@ sub handle_channel_text {
 }
 
 sub poll_feeds {
-    my ( $sock, $config ) = @_;
+    my ( $connections, $config ) = @_;
     return unless @{ $config->{feed_sources} // [] };
 
     my $state       = $config->{feed_state} //= {};
@@ -445,9 +535,11 @@ sub poll_feeds {
             my $label = $feed->{label} // 'Update';
             my $message = sprintf '[%s] %s - %s', $label, $title, $link;
 
-            for my $chan ( @{ $config->{channels} // [] } ) {
-                next unless $chan;
-                irc_print( $sock, "PRIVMSG $chan :$message" );
+            for my $connection ( @{$connections} ) {
+                for my $chan ( @{ $connection->{config}{channels} // [] } ) {
+                    next unless $chan;
+                    irc_print( $connection->{sock}, "PRIVMSG $chan :$message" );
+                }
             }
         }
     }
blob - 7eb77fbc249364e48a0ebd2e7a82bee57b5c136c
blob + 33bfa26f84742fa9ae5d8a2cfa1f181a7eccaecf
--- t/feed_polling.t
+++ t/feed_polling.t
@@ -88,4 +88,31 @@ subtest 'link is accepted when guid is absent' => sub 
 		['https://example.invalid/new'], 'link-only item is announced' );
 };
 
+subtest 'separate server configurations keep channels isolated' => sub {
+	local $ENV{CRYBOT_SUPERNETS_SERVER} = 'irc.supernets.org';
+	local $ENV{CRYBOT_SUPERNETS_PORT}   = 6697;
+	local $ENV{CRYBOT_SUPERNETS_CHANNEL} = '#supernets';
+
+	my @servers = build_server_configs(
+		{
+			server  => 'irc.libera.chat',
+			port    => 6697,
+			nick    => 'GCHQ',
+			altnick => 'GCHQ2',
+			login   => 'GCHQ',
+			version => 'GCHQ Bot',
+			channel => '#cryogenix',
+			pass    => 'test-password',
+		}
+	);
+
+	is( scalar @servers, 2, 'both server configurations are present' );
+	is( $servers[0]{server}, 'irc.libera.chat', 'primary server is preserved' );
+	is_deeply( $servers[0]{channels}, ['#cryogenix'],
+		'primary channel remains on the primary server' );
+	is( $servers[1]{server}, 'irc.supernets.org', 'Supernets server is configured' );
+	is_deeply( $servers[1]{channels}, ['#supernets'],
+		'Supernets channel is isolated to its server' );
+};
+
 done_testing;