commit - 90ffadc3bd024e8f62fa22e9e322923b8217e391
commit + d38bceff4a0824695b93b2bebc22a9e73c6b8fd6
blob - 330f390fdae9defea22c45d0327ae4627ac011f4
blob + 316d1678ba5d5811bcadd191187443b5b8f12c5c
--- crybot.pl
+++ crybot.pl
{
key => 'lobsters',
label => $ENV{CRYBOT_LOBSTERS_LABEL} // 'Lobsters',
- url => $ENV{CRYBOT_LOBSTERS_FEED} // 'https://lobste.rs/rss',
+ url => $ENV{CRYBOT_LOBSTERS_FEED} // 'https://lobste.rs/newest.rss',
},
],
);
help => \&cmd_help,
);
-main();
+main() unless caller;
sub main {
my $sock = connect_to_server(\%config);
my $key = $feed->{key} // $feed_url;
my $last_guid = $state->{$key}{last_guid};
+ my ( $cursor_status, $cursor_guid, $announce ) =
+ select_feed_updates( \@items, $last_guid );
- unless ( defined $last_guid ) {
- my $initial = $items[0];
- my $guid = $initial->{guid} // $initial->{link};
- if ($guid) {
- $state->{$key}{last_guid} = $guid;
- $state_changed = 1;
- }
- next;
+ if ( defined $cursor_guid &&
+ ( !defined $last_guid || $cursor_guid ne $last_guid ) ) {
+ $state->{$key}{last_guid} = $cursor_guid;
+ $state_changed = 1;
}
- my @announce;
- for my $item (@items) {
- my $guid = $item->{guid} // $item->{link};
- next unless $guid;
- last if defined $last_guid && $guid eq $last_guid;
- push @announce, $item;
+ if ( $cursor_status eq 'missing' ) {
+ debug_log( $config,
+ "[feed] saved cursor is absent from $feed_url; rebaselining" );
}
- next unless @announce;
- @announce = reverse @announce;
-
- for my $item (@announce) {
+ for my $item ( @{$announce} ) {
my $guid = $item->{guid} // $item->{link};
- if ($guid) {
- $state->{$key}{last_guid} = $guid;
- $state_changed = 1;
- }
next unless $send_output;
if $state_changed;
}
+sub select_feed_updates {
+ my ( $items, $last_guid ) = @_;
+ return ( 'empty', undef, [] )
+ unless ref $items eq 'ARRAY' && @{$items};
+
+ my $newest_guid = $items->[0]{guid} // $items->[0]{link};
+ return ( 'empty', undef, [] ) unless defined $newest_guid;
+ return ( 'initial', $newest_guid, [] )
+ unless defined $last_guid && length $last_guid;
+
+ my @announce;
+ my $cursor_found = 0;
+ for my $item ( @{$items} ) {
+ my $guid = $item->{guid} // $item->{link};
+ next unless defined $guid;
+ if ( $guid eq $last_guid ) {
+ $cursor_found = 1;
+ last;
+ }
+ push @announce, $item;
+ }
+
+ return ( 'missing', $newest_guid, [] ) unless $cursor_found;
+ return ( 'current', $newest_guid, [ reverse @announce ] );
+}
+
sub parse_rss_items {
my ($xml) = @_;
return unless defined $xml;
blob - /dev/null
blob + 7eb77fbc249364e48a0ebd2e7a82bee57b5c136c (mode 644)
--- /dev/null
+++ t/feed_polling.t
+# Crybot - Developed by lex0de (lex0de@tuta.com)
+# cryobot/t/feed_polling.t
+
+use v5.24;
+use strict;
+use warnings;
+
+use FindBin qw($Bin);
+use Test::More;
+
+my $script = "$Bin/../crybot.pl";
+my $loaded = do $script;
+die "Unable to load $script: $@ $!" unless defined $loaded;
+
+sub feed_item {
+ my ( $guid, $title ) = @_;
+ return {
+ guid => $guid,
+ link => "https://example.invalid/$guid",
+ title => $title,
+ };
+}
+
+subtest 'empty feed does not change the cursor' => sub {
+ my ( $status, $cursor, $updates ) = select_feed_updates( [], 'story-1' );
+ is( $status, 'empty', 'empty status returned' );
+ ok( !defined $cursor, 'cursor remains undefined' );
+ is_deeply( $updates, [], 'nothing announced' );
+};
+
+subtest 'first poll establishes a silent baseline' => sub {
+ my @items = ( feed_item( 'story-2', 'Second' ),
+ feed_item( 'story-1', 'First' ) );
+ my ( $status, $cursor, $updates ) = select_feed_updates( \@items, undef );
+ is( $status, 'initial', 'initial status returned' );
+ is( $cursor, 'story-2', 'newest item becomes cursor' );
+ is_deeply( $updates, [], 'initial feed is not announced' );
+};
+
+subtest 'unchanged feed produces no announcements' => sub {
+ my @items = ( feed_item( 'story-2', 'Second' ),
+ feed_item( 'story-1', 'First' ) );
+ my ( $status, $cursor, $updates ) =
+ select_feed_updates( \@items, 'story-2' );
+ is( $status, 'current', 'current status returned' );
+ is( $cursor, 'story-2', 'cursor remains current' );
+ is_deeply( $updates, [], 'nothing announced' );
+};
+
+subtest 'new entries are announced oldest first' => sub {
+ my @items = (
+ feed_item( 'story-4', 'Fourth' ),
+ feed_item( 'story-3', 'Third' ),
+ feed_item( 'story-2', 'Second' ),
+ feed_item( 'story-1', 'First' ),
+ );
+ my ( $status, $cursor, $updates ) =
+ select_feed_updates( \@items, 'story-2' );
+ is( $status, 'current', 'current status returned' );
+ is( $cursor, 'story-4', 'cursor advances to newest item' );
+ is_deeply( [ map { $_->{guid} } @{$updates} ],
+ [ 'story-3', 'story-4' ], 'announcements are chronological' );
+};
+
+subtest 'missing cursor rebaselines without replay' => sub {
+ my @items = (
+ feed_item( 'ranked-3', 'Third ranked story' ),
+ feed_item( 'ranked-1', 'First ranked story' ),
+ feed_item( 'ranked-2', 'Second ranked story' ),
+ );
+ my ( $status, $cursor, $updates ) =
+ select_feed_updates( \@items, 'removed-story' );
+ is( $status, 'missing', 'missing status returned' );
+ is( $cursor, 'ranked-3', 'cursor advances to current head' );
+ is_deeply( $updates, [], 'reordered feed is not replayed' );
+};
+
+subtest 'link is accepted when guid is absent' => sub {
+ my @items = (
+ { link => 'https://example.invalid/new', title => 'New' },
+ { link => 'https://example.invalid/old', title => 'Old' },
+ );
+ my ( $status, $cursor, $updates ) =
+ select_feed_updates( \@items, 'https://example.invalid/old' );
+ is( $status, 'current', 'current status returned' );
+ is( $cursor, 'https://example.invalid/new', 'link becomes cursor' );
+ is_deeply( [ map { $_->{link} } @{$updates} ],
+ ['https://example.invalid/new'], 'link-only item is announced' );
+};
+
+done_testing;