#!/usr/bin/perl # slacktopic.pl, by B. Watson . # Licensed under the WTFPL. See http://www.wtfpl.net/txt/copying/ for details. # This is an irssi script that updates the ##slackware topic any time # there's a new update in the Slackware ChangeLog. Place the script in # your ~/.irssi/scripts/autorun/ dir. # At script startup, and again every $update_frequency seconds, we # check for updates like so: # - Exec a curl process to get the first part of the ChangeLog # and extract the new date from it. # - Extract the old date from the current /topic. # - If the /topic has an old date, and if it's different from the new # date, retrieve the full ChangeLog entry (up to the first "+----..." line), # check for the string "(* Security fix *)" or similar. If nothing # found, it's not a security update, so don't update the topic. # - If it needs updating, update the /topic (really, get ChanServ to do # it). # Only the date (inside []) is changed, all the other stuff is left as-is. # Assumptions made: # - Pat won't be updating the ChangeLog several times in the same # minute or so. If he did, we might get confused about whether # or not an update is a security fix update. # - Client is set to autojoin ##slackware, or else the user will # always manually join it. Script doesn't do anything until # this happens. # - At some point we'll successfully log in to services. If the # script tries to check for updates before that happens, it # won't hurt anything, but the topic won't get updated either. # - We have enough ChanServ access on the channel to set the topic via # ChanServ's topic command (flag +t in access list). Again, no # harm done, but no topic updates either. # Notes: # - This script would possibly work for other FreeNode or Libera channels # that track a ChangeLog and update the /topic when there's # a change. You'd want to at least change @update_channels and # $update_cmd. If you're not on FreeNode/Libera, more surgery will be # required (if there's a way to change the /topic by talking to a # 'services' bot, it should be possible). # - Please don't try to talk me into using LWP and one of the Date:: # modules in place of executing curl and date. Slackware doesn't ship # them, plus they're huge and I don't want to keep them loaded in # irssi all the time. # References: # https://raw.githubusercontent.com/irssi/irssi/master/docs/perl.txt # http://wiki.foonetic.net/wiki/ChanServ_Commands use warnings; use strict; # I really wish I could just say 'use Irssi ":all"' here. use Irssi qw/ channel_find command command_bind servers signal_add_last timeout_add timeout_add_once timeout_remove window_find_name /; our $VERSION = "0.2"; our %IRSSI = ( authors => 'B. Watson', contact => 'urchlay@slackware.uk or Urchlay on libera.chat ##slackware', name => 'slacktopic', description => 'Updates ##slackware /topic whenever there\'s a ' . 'security update in the Slackware ChangeLog.', license => 'WTFPL', url => 'https://slackware.uk/~urchlay/repos/misc-scripts/plain/slacktopic.pl', ); ### Configurables. # TODO: make some or all of these config variables into irssi # settings? Probably overkill, this script is niche-market (probably # nobody but the author and one other person will ever run it...) # Print verbose debugging messages in local irssi window? our $DEBUG = 0; # For testing, fake the date. 0 = use the real ChangeLog date. If you # set this, it *must* match /\d\d\d\d-\d\d-\d\d/. #our $FAKE = '9999-99-99'; our $FAKE = 0; # Slackware ChangeLog URL. Version number is hardcoded here. Notice it's # the plain http URL, not https (which doesn't even exist). Actually, # ftp would also work, but then you got the whole passive vs. active # firewall mess. # Note: ftp.osuosl.org really is the primary Slackware site, but it # resolves to 3 different IPs (currently), which seem not to always # be in sync with each other. our $changelog_url = "http://ftp.osuosl.org/pub/slackware/slackware-15.0/ChangeLog.txt"; # Max time curl will spend trying to do its thing. It'll give up after # this many seconds, if it can't download the ChangeLog. our $cmd_timeout = 60; # $update_cmd will write its output here. It'll be in YYYY-MM-DD form, # which is just what's needed for the /topic. I prefer to keep this # in ~/.irssi, but it might be better in /tmp (especially if /tmp is # a tmpfs). our $cmd_outfile = "$ENV{HOME}/.irssi/slack_update.txt"; # We /exec this to get the first line of the ChangeLog. So long as Pat # follows his standard conventions, bytes 0-28 are the first line of # the ChangeLog. If you *really* wanted to, you could use wget instead # of curl, but it doesn't have the --range option... All the business # with rm and mv is to (try to) avoid ever reading the file when it's # only partially written. our $curl_args = "--silent --range 0-28 --max-time $cmd_timeout"; our $update_cmd = "rm -f $cmd_outfile ; " . "curl $curl_args $changelog_url |" . "date -u -f- '+%F' > $cmd_outfile.new ; " . "mv $cmd_outfile.new $cmd_outfile"; if($FAKE) { $update_cmd = "echo '$FAKE' > $cmd_outfile"; } # What channel(s) /topic are we updating? our @update_channels = ( "##slackware", "#slackware.uk" ); # What server are @update_channels supposed to be on? This is paranoid # maybe, AFAIK no other network uses the ## like freenode does, so # the channel name ##slackware should be enough to identify it. But, # ehhh, a little paranoia goes a long way... # 20210602 bkw: now there's libera.chat, which can be thought of as a # fork of freenode. our $server_regex = qr/\.libera\.chat$/; # Seconds between update checks. Every check executes $update_script, which # talks to ftp.slackware.com, so be polite here. our $update_frequency = 600; ### End of configurables. ### Bookkeeping stuffs. our $timeout_tag; our $child_proc; our $log_window; ### Functions. # Print a message to the status window, if there is one. Otherwise print # it to whatever the active window happens to be. Use this or one of # (err|debug|log)msg for all output, don't use regular print or warn. sub echo { if($log_window) { $log_window->print($_) for @_; } else { command("/echo $_") for @_; } } sub errmsg { my (undef, $file, $line) = caller; echo("$file:$line: $_") for @_; } sub debugmsg { goto &errmsg if $DEBUG; } sub logmsg { echo("$IRSSI{name}: $_") for @_; } # Called once at script load. sub init { $log_window = window_find_name("(status)") || window_find_name("(msgs)"); if($log_window) { logmsg("Logging to status window"); } else { logmsg("Logging to active window"); } debugmsg("init() called"); # This gets called any time an /exec finished. signal_add_last("exec remove", "finish_update"); # Command for manual update checks (without argument), or # forcing the date (with an argument). command_bind("slacktopic", "start_update"); # Check once at script load. initial_update(); if($update_frequency < 60) { # Typo protection. Ugh. errmsg("You didn't really mean to set \$update_frequency to " . "$update_frequency seconds, did you? Not starting timer. " . "Fix the script and reload it."); } else { # Also, automatically run it on a timer. 3rd argument unused here. timeout_add($update_frequency * 1000, "start_update", 0); } } # Return a list of the @update_channels we're actually joined to, or # undef (false) if none. sub get_channels { my @result; my $s; for(servers()) { $s = $_, last if($_->{address} =~ $server_regex); } if(!defined($s)) { errmsg("not connected to any server matching $server_regex"); return; } for(@update_channels) { my $chan = $s->channel_find($_); if(!$chan) { errmsg("not joined to $_ on " . $s->{address}); next; } push @result, $chan; } return @result; } # First update might need to be delayed. Usually we're being autoloaded at # irssi startup, and we might get called before autojoining the channel, # and/or before being logged in to services. Hard-coded 10 sec here. If # the IRC server or your ISP is being slow, the first update still might # fail. Oh well. sub initial_update { if(get_channels()) { start_update(); } else { timeout_add_once(10 * 1000, "start_update", 0); } } # Start the update process. sub start_update { my $force_date = shift || 0; debugmsg("start_update() called, force_date==$force_date"); if($force_date) { if($force_date !~ /^\d\d\d\d-\d\d-\d\d$/) { errmsg("Invalid date '$force_date'"); } else { set_topic_date($_, $force_date, 1) for get_channels(); } } else { # Don't do anything if we're not joined to the channel already. exec_update() if get_channels(); } } # Called when an /exec finishes. sub finish_update { debugmsg("finish_update() called"); my ($proc, $status) = @_; # We get called for *every* /exec. Make sure we only respond to # the right one. ## debugmsg("$proc->{name}: $status"); return unless $proc->{name} eq 'slacktopic_update'; if(defined($timeout_tag)) { timeout_remove($timeout_tag); undef $timeout_tag; undef $child_proc; } # ChanServ would let us change the topic even if we weren't in # the channel, but let's not do that. For one thing, it's a PITA # to retrieve the old topic, if we're not in the channel. # No debugmsg here, get_channels() already did it. my @chans = get_channels(); return unless @chans; # Get the date of the last update. my $new_date; open my $fh, "<$cmd_outfile" or do { errmsg("$cmd_outfile not found, update command failed"); return; }; chomp($new_date = <$fh>); close $fh; $new_date ||= ""; # This should never happen, but... if($new_date !~ /^\d\d\d\d-\d\d-\d\d$/) { errmsg("$cmd_outfile content isn't a valid date: '$new_date'"); return; } for(@chans) { set_topic_date($_, $new_date, 0); } } sub set_topic_date { my ($chan, $new_date, $force) = @_; # Get old topic, replace the date with the new one. debugmsg("set_topic_date() called, \$new_date is: $new_date"); my $t = $chan->{topic}; unless($t =~ s,\[(\d\d\d\d-\d\d-\d\d)\],[$new_date],) { errmsg("topic doesn't contain [yyyy-mm-dd] date, fix it manually"); return; } # 20230804 bkw: to avoid 2 instances of this script fighting each other when # they're using different mirrors (where one mirror hasn't yet updated but # the other one has), make sure $new_date actually is new. my $topic_date = $1; if($new_date lt $topic_date) { debugmsg("new_date $new_date is older than topic_date $topic_date, ignoring"); return; } # Don't do anything if the topic's already correct. if($t eq $chan->{topic}) { debugmsg("topic already correct, not doing anything"); return; } # Make sure this is a security fix update. if(!$force) { return unless is_security_update($new_date); } # Ask ChanServ to change the topic for us. We don't need +o in # the channel, so long as we're logged in to services and have +t. logmsg("ChangeLog updated [$new_date], asking ChanServ to update topic"); $chan->{server}->send_raw("ChanServ topic " . $chan->{name} . " $t"); } # Called if the child process times out ($cmd_timeout + 2 sec). sub update_timed_out { errmsg("child process timed out, killing it"); undef $timeout_tag; if(defined($child_proc) && defined($child_proc->{pid})) { kill 'KILL', $child_proc->{pid}; } undef $child_proc; } # Spawn $update_cmd. It'll either complete (in which case finish_update() # gets called) or time out (in which case, update_timed_out()). sub exec_update { debugmsg("exec_update() called"); if($timeout_tag) { errmsg("Timeout still active, not spawning new process"); return; } $child_proc = command("/exec - -name slacktopic_update $update_cmd"); $timeout_tag = timeout_add_once( 1000 * ($cmd_timeout + 2), 'update_timed_out', 0); # last arg is unused } # Without caching the last result, every non-security update would result # in us wasting bandwidth rechecking the ChangeLog every $update_frequency # sec. our $sup_last_date = ""; our $sup_last_result; # Return true if the first ChangeLog entry is a security update. Unlike # the regular check-for-update that happens periodically, this one blocks # for up to $cmd_timeout seconds. The updates only happen every few days, # I don't see this as a real problem that needs extra complexity to solve. # Notice we don't check the $date argument against the date read from # the file. sub is_security_update { my $date = shift; debugmsg("is_security_update($date) called"); if($date eq $sup_last_date) { debugmsg("already checked & got '$sup_last_result' for $date"); return $sup_last_result; } $sup_last_date = $date; debugmsg("getting start of ChangeLog"); my $result = 0; my $lines = 0; # Too bad we couldn't have kept the TCP connection open # from the previous run of curl. open my $pipe, "curl --silent --no-buffer --max-time $cmd_timeout $changelog_url|"; # Read lines until we hit "(* Security fix *)" or the separator that # ends the entry. Unfortunately, even with --no-buffer, we get a lot # more data than we need (no harm done, just wastes a bit of bandwidth). while(<$pipe>) { $lines++; # Allow Pat some typos (case insensitive, variable spacing). if(/\(\*\s*security\s+fix\s*\*\)/i) { $result = 1; last; } elsif(/^\+-+\+/) { # Separator between entries. last; } } my $curl_exit = (close $pipe) >> 8; # 23 is "error writing output" (from curl man page), this is expected # when we close the read pipe. If we get any other error, it's worth # complaining about. # Also, exit status 0 (success) isn't worth griping about. if(($curl_exit != 23) && ($curl_exit != 0)) { errmsg("curl exited with unexpected status: $curl_exit"); } debugmsg("read $lines lines from ChangeLog, returning $result"); $sup_last_result = $result; return $result; } ### main() init();