logbot-irc 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691
  1. #!/usr/bin/perl
  2. # bare-bones irc logging bot
  3. # saves events as json files for processing
  4. use local::lib;
  5. use v5.10;
  6. use strict;
  7. use warnings;
  8. use FindBin qw( $RealBin );
  9. use lib "$RealBin/lib";
  10. BEGIN { $ENV{TZ} = 'UTC' }
  11. use Data::Dumper qw( Dumper );
  12. use Encode qw( decode );
  13. use Encode::Guess;
  14. use Errno qw( ETIMEDOUT EWOULDBLOCK );
  15. use IO::Socket::INET ();
  16. use IO::Socket::SSL qw( $SSL_ERROR );
  17. use IO::Socket::Timeout ();
  18. use List::MoreUtils qw( natatime );
  19. use List::Util qw( any min );
  20. use LogBot::Config qw( find_config load_config reload_config save_config );
  21. use LogBot::JobQueue;
  22. use LogBot::MemCache ();
  23. use LogBot::Util qw( file_for logbot_init normalise_channel slurp source_to_nick squash_error timestamp touch );
  24. use Mojo::Log ();
  25. use Readonly;
  26. use Time::HiRes ();
  27. use Try::Tiny qw( catch try );
  28. # globals
  29. my ($config, $state, $connection, $job_queue, $log, $memcache);
  30. #
  31. # read message from irc server, or undef if no message ready or disconnected
  32. sub irc_read {
  33. return undef unless $connection;
  34. my $message = $connection->getline();
  35. # timeout
  36. if (!$message && (0 + $! == ETIMEDOUT || 0 + $! == EWOULDBLOCK)) {
  37. return undef;
  38. }
  39. # disconnect
  40. if (!defined $message) {
  41. undef $connection;
  42. return undef;
  43. }
  44. $message =~ s/^\@\S+ +//; # strip tags
  45. $message =~ s/[\r\n]+$//; # strip eol
  46. if (substr($message, 0, 1) ne ':') { # add source
  47. $message = ':- ' . $message;
  48. }
  49. # print all server messages (except ping/pong to reduce noise)
  50. say timestamp(), ' << ', $message
  51. if ($message !~ /^:\S+ P[IO]NG /) || $ENV{DEBUG};
  52. return $message;
  53. }
  54. # send raw irc command to server
  55. sub irc_send {
  56. my ($message) = @_;
  57. # print all client messages (except ping/pong to reduce noise)
  58. say timestamp(), ' >> ', $message
  59. if ($message !~ /^P[IO]NG /) || $ENV{DEBUG};
  60. $connection->print($message . "\r\n");
  61. }
  62. # send private message
  63. sub irc_send_message {
  64. my ($target, $message) = @_;
  65. irc_send('PRIVMSG ' . $target . ' :' . $message);
  66. }
  67. # connect to irc server
  68. sub irc_connect {
  69. my $irc_host = $config->{irc}->{host};
  70. my $ssl = $irc_host =~ s/^ssl:// || $irc_host =~ /:6697$/;
  71. say timestamp(), ' -- connecting to irc on ', $irc_host, ($ssl ? ' (ssl)' : '');
  72. if ($ssl) {
  73. $connection = IO::Socket::SSL->new($irc_host)
  74. or die "connection to $irc_host failed: $! $SSL_ERROR\n";
  75. } else {
  76. $connection = IO::Socket::INET->new($irc_host)
  77. or die "connection to $irc_host failed: $@\n";
  78. }
  79. IO::Socket::Timeout->enable_timeouts_on($connection);
  80. $connection->read_timeout(0.5);
  81. # login
  82. irc_send('USER ' . $config->{irc}->{nick} . ' 0 * :' . $config->{irc}->{real_name});
  83. irc_send('NICK ' . $config->{irc}->{nick});
  84. # wait for connect (end of motd)
  85. while (1) {
  86. my $message = irc_read();
  87. die 'Disconnected' . ($ssl ? '' : ' (possible SSL mismatch)') . "\n" unless $connection;
  88. exit if $state->{quit};
  89. next unless $message;
  90. if ($message =~ /^:\S+ PING (.+)/) {
  91. irc_send('PONG ' . $1);
  92. next;
  93. }
  94. next unless $message =~ /^:(\S+) (\d+) /;
  95. my ($server, $code) = ($1, $2);
  96. die 'Nick ' . $config->{irc}->{nick} . " in use\n" if $code eq '433'; # ERR_NICKNAMEINUSE
  97. if ($code eq '376') { # RPL_ENDOFMOTD
  98. $state->{server} = $server;
  99. last;
  100. }
  101. }
  102. # identify
  103. if ($config->{irc}->{password}) {
  104. irc_send_message('NickServ', 'identify ' . $config->{irc}->{password});
  105. }
  106. }
  107. sub block_invite {
  108. my ($source, $channel) = @_;
  109. my $who = source_to_nick($source);
  110. foreach my $blocked (@{ $config->{blocked} }) {
  111. if (substr($blocked, 0, 1) eq '#') {
  112. return 1 if $blocked eq $channel;
  113. } else {
  114. my $umask = quotemeta(lc($blocked));
  115. $umask =~ s/\\\*/[\x01-\xFF]{0,}/g;
  116. $umask =~ s/\\\?/[\x01-\xFF]{1,1}/g;
  117. return 1 if lc($source) =~ /^$umask$/;
  118. }
  119. }
  120. return 0;
  121. }
  122. sub init_logging {
  123. $log = Mojo::Log->new(
  124. path => ($config->{_derived}->{is_dev} ? 'log' : '/var/log/logbot') . '/irc_' . $config->{name} . '.log',
  125. level => 'info',
  126. );
  127. }
  128. #
  129. # publish event to processing queue
  130. sub publish {
  131. my ($type, $nick, $channel, $text) = @_;
  132. return unless exists $config->{channels}->{$channel};
  133. return if $config->{channels}->{$channel}->{no_logs};
  134. try {
  135. # decode
  136. my $utf8 = guess_encoding($text, 'utf8');
  137. $text = $utf8 ? decode('utf8', $text) : decode('cp1252', $text);
  138. # strip colours, formatting
  139. $text =~ s/\x03(?:,\d{1,2}|\d{1,2}(?:,\d{1,2})?)?//g; # mirc
  140. $text =~ s/\x04[0-9a-fA-F]{0,6}//g; # rgb
  141. $text =~ s/\x1B\[.*?[\x00-\x1F\x40-\x7E]//g; # ecma-84
  142. $text =~ s/[\x02\x1f\x16\x1d\x11\x06]//g; # formatting
  143. $text =~ s/\x0f//g; # cancellation
  144. $job_queue->publish_job(
  145. {
  146. time => Time::HiRes::time(),
  147. channel => $channel,
  148. type => $type,
  149. nick => $nick,
  150. text => $text,
  151. }
  152. );
  153. }
  154. catch {
  155. say timestamp(), ' !! ', squash_error($_);
  156. $log->error(squash_error($_));
  157. };
  158. }
  159. #
  160. # init config
  161. $config = load_config(find_config(shift))
  162. // die "syntax: logbot-irc <config file> [--reload][--cyclelogs][--debug][--quit]\n";
  163. # pid
  164. my $pid_file = file_for($config, 'pid', 'logbot-irc');
  165. my $pid = 0;
  166. if (-e $pid_file) {
  167. chomp($pid = slurp($pid_file));
  168. $pid = 0 unless kill(0, $pid);
  169. }
  170. # commands / signals
  171. if (@ARGV) {
  172. if ($ARGV[0] eq '--reload') {
  173. $pid || die 'logbot-irc (' . $config->{name} . ") is not running\n";
  174. kill('HUP', $pid);
  175. say "reload request sent to pid $pid";
  176. } elsif ($ARGV[0] eq '--debug') {
  177. $pid || die 'logbot-irc (' . $config->{name} . ") is not running\n";
  178. kill('USR1', $pid);
  179. say "debug-dump request sent to pid $pid";
  180. } elsif ($ARGV[0] eq '--cyclelogs') {
  181. $pid || die 'logbot-irc (' . $config->{name} . ") is not running\n";
  182. kill('USR2', $pid);
  183. say "cyclelogs request sent to pid $pid";
  184. } elsif ($ARGV[0] eq '--quit') {
  185. $pid || die 'logbot-irc (' . $config->{name} . ") is not running\n";
  186. kill('INT', $pid);
  187. say "quit request sent to pid $pid";
  188. } else {
  189. die "unrecognised parameter\n";
  190. }
  191. exit;
  192. }
  193. # init
  194. STDOUT->autoflush(1);
  195. $pid && die 'logbot-irc (' . $config->{name} . ") is already running\n";
  196. logbot_init($config);
  197. init_logging();
  198. $job_queue = LogBot::JobQueue->new($config);
  199. $memcache = LogBot::MemCache->new(binary => !$config->{_derived}->{is_dev});
  200. # init signals and state
  201. $SIG{HUP} = sub {
  202. $state->{next_channel_reload} = time();
  203. };
  204. $SIG{USR1} = sub { $state->{dump} = 1 };
  205. $SIG{USR2} = sub { $state->{relog} = 1 };
  206. $SIG{INT} = sub { $state->{quit} = 1 };
  207. $state->{pending_invites} = {};
  208. # event loop
  209. while (1) {
  210. # connect/reconnect
  211. while (!$connection) {
  212. exit if $state->{quit};
  213. try {
  214. # try to connect
  215. irc_connect();
  216. # connected, setup initial state
  217. $log->info('connected to ' . $config->{irc}->{host});
  218. delete $state->{backoff};
  219. $state->{next_channel_reload} = time();
  220. $state->{next_topic_reload} = time() + 5 * 60;
  221. $state->{next_ping} = time() + $config->{timing}->{initial_ping_delay};
  222. $state->{pong_timeouts} = 0;
  223. $state->{touch_file} = file_for($config, 'connected');
  224. touch($state->{touch_file});
  225. }
  226. catch {
  227. # connection failed, retry with backoff
  228. say timestamp(), ' !! ', squash_error($_);
  229. $log->error(squash_error($_));
  230. undef($connection);
  231. $state->{backoff} = min(($state->{backoff} // 1) * 2, $config->{timing}->{max_reconnect_interval});
  232. say timestamp(), ' ** sleeping for ', $state->{backoff} if $ENV{DEBUG};
  233. sleep($state->{backoff});
  234. };
  235. }
  236. my $message = irc_read();
  237. my $time = time();
  238. # ping timer
  239. if (exists $state->{pong_timeout} && $state->{pong_timeout} <= $time) {
  240. $state->{pong_timeouts}++;
  241. $state->{next_ping} = $time;
  242. say timestamp(), ' !! PING timeout (', $state->{pong_timeouts}, ')';
  243. $log->error('PING timeout (' . $state->{pong_timeouts} . ')');
  244. # server not responding to our pings, reconnect
  245. if ($state->{pong_timeouts} == $config->{timing}->{ping_timeout_attempts}) {
  246. undef($connection);
  247. next;
  248. }
  249. }
  250. if (exists $state->{next_ping} && $state->{next_ping} <= $time) {
  251. delete $state->{next_ping};
  252. $state->{pong_timeout} = $time + $config->{timing}->{ping_timeout};
  253. irc_send('PING :' . $state->{server});
  254. }
  255. # update stored channel topics
  256. if (exists $state->{next_topic_reload} && $state->{next_topic_reload} <= $time) {
  257. if (keys %{ $state->{channels_in} }) {
  258. say timestamp(), ' -- refresh topics';
  259. $state->{pending_topics} = [sort keys %{ $state->{channels_in} }];
  260. $state->{next_pending_topics} = $time;
  261. }
  262. $state->{next_topic_reload} = time() + $config->{timing}->{topic_reload_interval};
  263. }
  264. if (exists $state->{next_pending_topics} && $state->{next_pending_topics} <= $time) {
  265. irc_send('TOPIC ' . shift(@{ $state->{pending_topics} }));
  266. if (@{ $state->{pending_topics} }) {
  267. $state->{next_pending_topics} = time() + 1;
  268. } else {
  269. delete $state->{next_pending_topics};
  270. delete $state->{pending_topics};
  271. }
  272. }
  273. # trigger reconciliation of joined channels
  274. if (exists $state->{next_channel_reload} && $state->{next_channel_reload} <= $time) {
  275. say timestamp(), ' -- reload channel config';
  276. delete $state->{next_channel_reload};
  277. $state->{channels_reconcile} = {};
  278. irc_send('WHOIS ' . $config->{irc}->{nick});
  279. }
  280. # dump config and state on SIGUSR1
  281. if (delete $state->{dump}) {
  282. print Dumper($config);
  283. print Dumper($state);
  284. }
  285. # drop log handle on SIGUSR2
  286. if (delete $state->{relog}) {
  287. say timestamp(), ' -- cycling logs';
  288. init_logging();
  289. }
  290. # quit cleanly when requested
  291. if (delete $state->{quit}) {
  292. say timestamp(), ' -- quit requested';
  293. $log->info('quitting');
  294. irc_send('QUIT');
  295. last;
  296. }
  297. next unless $message;
  298. # server initiated ping
  299. if ($message =~ /^:\S+ PING (.+)/) {
  300. irc_send('PONG ' . $1);
  301. next;
  302. }
  303. # response to our ping
  304. if ($message =~ /^:\S+ PONG /) {
  305. delete $state->{pong_timeout};
  306. $state->{pong_timeouts} = 0;
  307. $state->{next_ping} = $time + $config->{timing}->{ping_interval};
  308. touch($state->{touch_file});
  309. next;
  310. }
  311. # invited
  312. if ($message =~ /^:(\S+) INVITE \S+ :(#.+)/) {
  313. my ($source, $channel) = ($1, normalise_channel($2));
  314. my $who = source_to_nick($source);
  315. $config = reload_config($config);
  316. # honour invite blocklist
  317. if (block_invite($source, $channel)) {
  318. say timestamp(), ' -- ignoring invite to blocked ', $channel, ' from ', $source;
  319. $log->info('ignoring invite to blocked ' . $channel . ' from ' . $source);
  320. next;
  321. }
  322. # ignore no-op invites
  323. if ($state->{channels_in}->{$channel}) {
  324. say timestamp(), ' -- ignoring invite to already-in ', $channel, ' from ', $who;
  325. $log->info('ignoring invite to already-in ' . $channel . ' from ' . $who);
  326. next;
  327. }
  328. # can't log channels starting with underscores - used for internal web calls
  329. if (substr($channel, 1, 1) eq '_') {
  330. irc_send_message($who, 'unable to join ' . $channel . ': unable to log channels starting with underscore');
  331. $log->info('rejecting invite to underscored ' . $channel . ' from ' . $who);
  332. next;
  333. }
  334. # can't log channels starting with # - confuses channel normalisation
  335. if (substr($channel, 1, 1) eq '#') {
  336. irc_send_message($who, 'unable to join ' . $channel . ': unable to log channels starting with ##');
  337. $log->info('rejecting invite to double-hashes ' . $channel . ' from ' . $who);
  338. next;
  339. }
  340. # cooldown
  341. my $cooldown_key = $config->{name} . $channel . ',' . $source;
  342. if (my $last_request = $memcache->get($cooldown_key)) {
  343. if ($time - $last_request < $config->{timing}->{invite_cooldown}) {
  344. irc_send_message($who, "unable to join $channel: please wait longer before asking again");
  345. $log->info("rejecting invite to $channel from $who: cooldown expires in "
  346. . ($config->{timing}->{invite_cooldown} - ($time - $last_request))
  347. . 's');
  348. next;
  349. }
  350. }
  351. $state->{pending_invites}->{$channel} = {
  352. who => lc($who),
  353. source => $source,
  354. ops => [],
  355. cooldown_key => $cooldown_key,
  356. };
  357. irc_send('JOIN ' . $channel);
  358. next;
  359. }
  360. # joined
  361. if ($message =~ /^:(\S+) JOIN :(#.+)/) {
  362. my ($who, $channel) = (source_to_nick($1), normalise_channel($2));
  363. next unless $who eq $config->{irc}->{nick};
  364. # if we're joining due to an invite, check the requester is an op
  365. if (exists $state->{pending_invites}->{$channel}) {
  366. say timestamp(), ' ** found pending-invite for ', $channel if $ENV{DEBUG};
  367. irc_send('NAMES ' . $channel);
  368. next;
  369. }
  370. $state->{channels_in}->{$channel} = 1;
  371. irc_send('MODE ' . $channel); # trigger mode to grab channel password
  372. next;
  373. }
  374. # parted
  375. if ($message =~ /^:(\S+) PART :(#.+)/) {
  376. my ($who, $channel) = (source_to_nick($1), normalise_channel($2));
  377. next unless $who eq $config->{irc}->{nick};
  378. delete $state->{channels_in}->{$channel};
  379. next;
  380. }
  381. # RPL_NAMREPLY (pending-invites)
  382. if ($message =~ /^:\S+ 353 (\S+) . (#\S+) :(.+)$/) {
  383. my ($who, $channel, $nicks) = ($1, normalise_channel($2), $3);
  384. next unless $who eq $config->{irc}->{nick};
  385. next unless exists $state->{pending_invites}->{$channel};
  386. # ~owners, &admins, and @ops (note this excludes %half-ops)
  387. foreach my $nick (split(' ', $nicks)) {
  388. next unless $nick =~ s/^[~&@]//;
  389. push @{ $state->{pending_invites}->{$channel}->{ops} }, lc($nick);
  390. }
  391. next;
  392. }
  393. # RPL_ENDOFNAMES (pending-invites)
  394. if ($message =~ /^:\S+ 366 (\S+) (#\S+) :/) {
  395. my ($who, $channel) = ($1, normalise_channel($2));
  396. next unless $who eq $config->{irc}->{nick};
  397. next unless exists $state->{pending_invites}->{$channel};
  398. my $invite = delete $state->{pending_invites}->{$channel};
  399. if ($ENV{DEBUG}) {
  400. say timestamp(), ' ** ', $channel, ' invited by: ', $invite->{who};
  401. say timestamp(), ' ** ', $channel, ' ops: ', join(' ', @{ $invite->{ops} });
  402. }
  403. if (any { $_ eq $invite->{who} } @{ $invite->{ops} }) {
  404. say timestamp(), ' -- joined ', $channel, ' via invite from ', $invite->{source};
  405. $log->info('joined ' . $channel . ' via invite from ' . $invite->{source});
  406. $config->{channels}->{$channel}->{invite} = timestamp() . ' <' . $invite->{source} . '>';
  407. delete $config->{channels}->{$channel}->{disabled};
  408. delete $config->{channels}->{$channel}->{archived};
  409. save_config($config);
  410. $state->{channels_in}->{$channel} = 1;
  411. irc_send('MODE ' . $channel); # trigger mode to grab channel password
  412. my $url = $config->{url} . substr($channel, 1);
  413. my $announcement = 'channel logging requested by ' . $invite->{who} . ': ' . $url;
  414. irc_send_message($channel, $announcement);
  415. publish(0, $config->{irc}->{nick}, $channel, $announcement);
  416. } else {
  417. say timestamp(), ' -- join ', $channel, ' rejected - non-op invite from ', $invite->{source};
  418. $log->info('join ' . $channel . ' rejected - non-op invite from ' . $invite->{source});
  419. irc_send('PART ' . $channel);
  420. irc_send_message($invite->{who}, 'unable to join ' . $channel . ': you are not a channel op');
  421. $memcache->set($invite->{cooldown_key} => $time);
  422. }
  423. next;
  424. }
  425. # join failed
  426. # NOSUCHCHANNEL, TOOMANYCHANNELS NEEDMOREPARAMS, INVITEONLYCHAN, BANNEDFROMCHAN, BADCHANNELKEY
  427. if ($message =~ /^:\S+ (403|405|461|471|473|474|475) (\S+) (#\S+) :(.+)/) {
  428. my ($code, $who, $channel, $text) = ($1, normalise_channel($2), $3);
  429. next unless $who eq $config->{irc}->{nick};
  430. say timestamp(), ' -- join ', $channel, ' failed: ', $text;
  431. $log->error('join ' . $channel . ' failed: ' . $text);
  432. delete $state->{channels_in}->{$channel};
  433. $config->{channels}->{$channel}->{error} = timestamp() . ' ' . $text;
  434. $config->{channels}->{$channel}->{archived} = ($code == 405 ? 1 : 0);
  435. save_config($config);
  436. if (my $invite = delete $state->{pending_invites}->{$channel}) {
  437. irc_send_message($invite->{who}, 'unable to join ' . $channel . ': ' . $text);
  438. }
  439. next;
  440. }
  441. # unable to send to channel - likely means we've been banned and then invited back
  442. # treat the same as banned and leave the channel
  443. if ($message =~ /^:\S+ 404 (\S+) (#\S+) :(.+)/) {
  444. my ($who, $channel, $text) = ($1, normalise_channel($2), $3);
  445. next unless $who eq $config->{irc}->{nick};
  446. say timestamp(), ' -- join ', $channel, ' failed: ', $text;
  447. $log->error('join ' . $channel . ' failed: ' . $text);
  448. delete $state->{channels_in}->{$channel};
  449. $config->{channels}->{$channel}->{archived} = 0;
  450. $config->{channels}->{$channel}->{error} = timestamp() . ' ' . $text;
  451. save_config($config);
  452. irc_send('PART ' . $channel . ' I\'m banned from this channel');
  453. next;
  454. }
  455. # mode --> extract channel password
  456. if ($message =~ /^:\S+ 324 \S+ (#\S+) (\S+) (\S+)/) {
  457. my ($channel, $mode, $password) = (normalise_channel($1), $2, $3);
  458. next unless $mode =~ /k/;
  459. next if ($config->{channels}->{$channel}->{password} // '') eq $password;
  460. $config->{channels}->{$channel}->{password} = $password;
  461. save_config($config);
  462. next;
  463. }
  464. # when kicked track why, and don't try to rejoin
  465. if ($message =~ /^:(\S+) KICK (#\S+) (\S+) :(.*)/) {
  466. my ($who, $channel, $kicked, $text) = (source_to_nick($1), normalise_channel($2), $3, $4);
  467. next unless $kicked eq $config->{irc}->{nick};
  468. $text = 'kicked' if ($text // '') eq '';
  469. say timestamp(), ' -- kicked from ', $channel, ' by ', $who, ': ', $text;
  470. $log->info('kicked from ' . $channel . ' by ' . $who . ': ' . $text);
  471. delete $state->{channels_in}->{$channel};
  472. $config->{channels}->{$channel}->{disabled} = 1;
  473. $config->{channels}->{$channel}->{kick} = timestamp() . ' <' . $who . '> ' . $text;
  474. save_config($config);
  475. next;
  476. }
  477. # channel /me ctcp action
  478. if ($message =~ /^:(\S+) PRIVMSG (#\S+) :\x01ACTION (.+)\x01/) {
  479. publish(1, source_to_nick($1), normalise_channel($2), $3);
  480. next;
  481. }
  482. # channel message
  483. if ($message =~ /^:(\S+) PRIVMSG (#\S+) :(.+)/) {
  484. publish(0, source_to_nick($1), normalise_channel($2), $3);
  485. next;
  486. }
  487. # channel notice
  488. if ($message =~ /^:(\S+) NOTICE (#\S+) :(.+)/) {
  489. publish(2, source_to_nick($1), normalise_channel($2), $3);
  490. next;
  491. }
  492. # topic updated
  493. if ($message =~ /^:\S+ TOPIC (#\S+) :(.+)/) {
  494. publish(3, '-', normalise_channel($1), $2);
  495. next;
  496. }
  497. # topic (RPL_NOTOPIC, RPL_TOPIC)
  498. if ($message =~ /^:\S+ (331|332) \S+ (#\S+) :(.+)/) {
  499. publish(3, '-', normalise_channel($2), $1 eq '331' ? '' : $3);
  500. next;
  501. }
  502. # private message
  503. if ($message =~ /^:(\S+) PRIVMSG \S+ :.+/) {
  504. my $who = source_to_nick($1);
  505. irc_send_message($who, $config->{help}) if $config->{help};
  506. }
  507. # channel reconciliation RPL_WHOISCHANNELS
  508. if ($message =~ /^:\S+ 319 \S+ \S+ :(.+)/) {
  509. my $channels = $1;
  510. foreach my $channel (split(' ', $channels)) {
  511. $channel =~ s/^[^#]+//; # strip usermode prefix
  512. $state->{channels_reconcile}->{ normalise_channel($channel) } = 1;
  513. }
  514. next;
  515. }
  516. # channel reconciliation RPL_ENDOFWHOIS
  517. if ($message =~ /^:\S+ 318 /) {
  518. $config = reload_config($config);
  519. # join channels
  520. my @join;
  521. foreach my $channel (sort keys %{ $config->{channels} }) {
  522. next if $config->{channels}->{$channel}->{disabled};
  523. next if $config->{channels}->{$channel}->{archived};
  524. # update channels_in (in)
  525. if ($state->{channels_reconcile}->{$channel}) {
  526. $state->{channels_in}->{$channel} = 1;
  527. next;
  528. }
  529. # join channels with passwords immediately
  530. if (exists $config->{channels}->{$channel}->{password}) {
  531. irc_send('JOIN ' . $channel . ' ' . $config->{channels}->{$channel}->{password});
  532. $log->info('joining ' . $channel);
  533. } else {
  534. push @join, $channel;
  535. }
  536. }
  537. # 10 at a time
  538. my $iter = natatime(10, @join);
  539. while (my @join_channels = $iter->()) {
  540. irc_send('JOIN ' . join(',', @join_channels));
  541. $log->info('joining ' . join(' ', @join_channels));
  542. }
  543. # part channels
  544. my @part;
  545. foreach my $channel (sort keys %{ $state->{channels_reconcile} }) {
  546. next if $config->{channels}->{$channel};
  547. push @part, $channel;
  548. }
  549. # 10 at a time
  550. $iter = natatime(10, @part);
  551. while (my @part_channels = $iter->()) {
  552. irc_send('PART ' . join(',', @part_channels));
  553. $log->info('parting ' . join(' ', @part_channels));
  554. }
  555. # update channels_in (out)
  556. foreach my $channel (sort keys %{ $state->{channels_in} }) {
  557. next if $state->{channels_reconcile}->{$channel};
  558. $log->info('unexpectedly no longer in ' . $channel);
  559. delete $state->{channels_in}->{$channel};
  560. }
  561. delete $state->{channels_reconcile};
  562. $state->{next_channel_reload} = time() + $config->{timing}->{channel_reload_interval};
  563. next;
  564. }
  565. }
  566. __END__
  567. connect
  568. -> channel reload
  569. -> +5m topic reload
  570. channel reload
  571. -> WHOIS logbot
  572. <= RPL_WHOISCHANNELS
  573. - collect channels in
  574. <= RPL_ENDOFWHOIS
  575. - diff channels-in with config
  576. - join/part as required
  577. topic reload
  578. -> TOPIC (foreach channel)
  579. <= RPL_TOPIC / RPL_NOTOPIC
  580. - update stored topic
  581. invite to channel
  582. <= INVITE
  583. - store invite request
  584. -> JOIN channel
  585. <= JOINED pending invite channel
  586. -> NAMES channel
  587. <= RPL_NAMREPLY pending invite channel
  588. - collect owners, admins, and ops
  589. <= RPL_ENDOFNAMES pending invite channel
  590. - if invite from owner/admin/op update config
  591. - otherwise PART