#!/usr/bin/perl

# slacktopic.pl, by B. Watson <urchlay@slackware.uk>.
# 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();
