aboutsummaryrefslogtreecommitdiff
path: root/slacktopic.pl
blob: 5f4a33767542a91df01baa94af90c6ab98949884 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
#!/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();