mata_bot

some cheeky bot for inspired by https://analognowhere.com comics
git clone https://git.pastanoggin.com/mata_bot.git
Log | Files | Refs | README | LICENSE

mata_bot.pl (12975B)


      1 #!/usr/bin/perl
      2 use v5.40;
      3 
      4 use Getopt::Std;
      5 use HTTP::Tiny;
      6 use IO::Select;
      7 use IO::Socket qw(AF_INET SOCK_STREAM);
      8 use IO::Socket::SSL;
      9 use POSIX qw(strftime);
     10 
     11 my $CHUNK_LENGTH = 1024;
     12 my $CONNECT_TIMEOUT = 60;
     13 my $RECONNECT_TIME = 60;
     14 my $SOCK_TIMEOUT = 10;
     15 my $PING_TIMEOUT = 120;
     16 my $PONG_TIMEOUT = 60;
     17 my $DEFAULT_CHAN = '#testmatabot';
     18 my $DEFAULT_HOST = 'localhost';
     19 my $DEFAULT_LOGGING = 0;
     20 my $DEFAULT_LOGLEVEL = 'none';
     21 my $DEFAULT_PATH_BALL = '/usr/local/share/matabot/ball';
     22 my $DEFAULT_PATH_HELLOS = '/usr/local/share/matabot/hellos';
     23 my $DEFAULT_PATH_QUOTES = '/usr/local/share/matabot/quotes';
     24 my $DEFAULT_PORT = 6667;
     25 my $DEFAULT_TLS = 0;
     26 my $MATA_CUTE = '[>.<]';
     27 my $MATA_DEAD = '[x~x]';
     28 my $MATA_FLIPOFF = 't[-_-t]';
     29 my $MATA_HAPPY = '[^_^]';
     30 my $MATA_NORM = '[._.]';
     31 my $MATA_SING = '[^=^]';
     32 my $MOTHER = 'noodle';
     33 my $NICK = 'mata_bot';
     34 my $NICK_RE = qr/mata_?bo[ity]+/i;
     35 my $REAL = 'death to technomage!!';
     36 my $USER = 'mata_bot_beta4';
     37 
     38 sub randint {
     39 	my($min, $max) = @_;
     40 	return $min if $min == $max;
     41 	($min, $max) = ($max, $min) if  $min > $max;
     42 	return $min + int rand(1 + $max - $min);
     43 }
     44 
     45 sub respond_command {
     46 	my ($ball, $hellos, $sender_nick, $content) = @_;
     47 	my $reply;
     48 	if ($content =~ /\b{wb}([1-9]\d*)?d([1-9]\d*)\b{wb}/) {
     49 		# we got dice
     50 		my $ndice = $1 // 1;
     51 		my $nface = $2;
     52 		my $min = $ndice;
     53 		my $max = $ndice * $nface;
     54 		my $result = $min + int rand(1 + $max - $min);
     55 		my $roll = 'd' . $nface;
     56 		$roll = $ndice . $roll if $ndice > 1;
     57 		$reply = "${sender_nick} rolled a ${roll} and got ${result}! ${MATA_HAPPY}";
     58 	} elsif ($content =~ /\b{wb}pray+\b{wb}/) {
     59 		$reply = "Stay prayed up!! ${MATA_CUTE}";
     60 	} elsif ($content =~ /\b{wb}bru[hv]+\b{wb}/i) {
     61 		$reply = "Did i stutter? ${MATA_NORM}";
     62 	} elsif ($content =~ /\b{wb}f+(u?ck+)? *(yo)?u+\b{wb}/i) {
     63 		$reply = $MATA_FLIPOFF
     64 	} elsif ($content =~ /\b{wb}i+ *l+(ove+)? *(you+|y+|u+)\b{wb}/i) {
     65 		$reply = "<3 ${MATA_HAPPY}"
     66 	} elsif ($content =~ /(thanks+|thx+|thank +(yo)?u+)( *a+ *lot+)?/i) {
     67 		$reply = "You're welcome, ${sender_nick}! ${MATA_HAPPY}"
     68 	} elsif ($content =~ /who('| +i|)s+ +(a|the)+ +goo+d+ +(bo+[ity]+o*|gir(l+|lie+))/i) {
     69 		$reply = "Me! ${MATA_CUTE}"
     70 	} elsif ($content =~ /
     71 		h(ow+)?\s*(are+|r+)\s*(you+|yo+|u+)(\s+doing+)?(\s+today+)?|
     72 		how('|\si|)s+\s+(it+\s+going+|life+|everything+)
     73 		/ix) {
     74 		$reply = "I feel fantaaaastic... hey, hey, hey! ${MATA_SING}"
     75 	} elsif ($content =~ /\b{wb}(
     76 		what('|\si|)s+\s*(up+|happening+|cracking+)|
     77 		(was)?sup+
     78 		)\b{wb}/ix) {
     79 		$reply = "Looking for technomage, and you? ${MATA_NORM}"
     80 	} elsif ($content =~ /\?$/) {
     81 		# we got a question
     82 		$reply = "$ball->[rand @$ball] ${MATA_CUTE}"
     83 	} elsif ($content =~ /\b{wb}(
     84 		goo+d+\s*(bo+[ity]+o*|gir(l+|lie+))|
     85 		w(elcome+)?\s*(b+|back+)
     86 		)\b{wb}/ix) {
     87 		if ($sender_nick eq $MOTHER) {
     88 			$reply = "Thank you, mother! ${MATA_HAPPY}"
     89 		} else {
     90 			$reply = "Thanks, ${sender_nick}! ${MATA_HAPPY}"
     91 		}
     92 	} elsif ($content =~ /\b{wb}(
     93 		(al+-?)?sala+m+u*\s+alaikum+u*|
     94 		(morn|even)ings+|
     95 		g('day+|oo+d\s+(day+|morning+|afternoo+n+|evening+))|
     96 		greetings+|
     97 		h(([aeo](i+|llo+)|ey+|i+(ya+)?)(\s+there+)?|owdy+)|
     98 		hail+|
     99 		noo+nafters+|
    100 		oi+|
    101 		salutations+|
    102 		well+\s+met+|
    103 		yo+
    104 		)\b{wb}/ix) {
    105 		$reply = "$hellos->[rand @$hellos], ${sender_nick}! ${MATA_HAPPY}"
    106 	} elsif ($sender_nick eq $MOTHER) {
    107 		$reply = "Done, mother! ${MATA_HAPPY}"
    108 	} else {
    109 		$reply = "\1ACTION leans over and places it's hand near it's antenna. \"HUUH?\" ${MATA_NORM}\1";
    110 	}
    111 	return $reply;
    112 }
    113 
    114 sub respond_mention {
    115 	my ($quotes, $sender_nick, $message) = @_;
    116 	my $reply;
    117 	if ($sender_nick eq $MOTHER) {
    118 		$reply = "Yes, mother? ${MATA_HAPPY}";
    119 	} elsif ($message =~ /^\b${NICK_RE}\b$/) {
    120 		$reply = "${MATA_NORM} ?";
    121 	} else {
    122 		$reply = "$quotes->[rand @$quotes] ${MATA_NORM}";
    123 	}
    124 	return $reply;
    125 }
    126 
    127 # respond to channel
    128 sub respond {
    129 	my ($lists, $subbuffer, $sender_nick, $message) = @_;
    130 	my $reply;
    131 	if ($subbuffer->{$sender_nick} && $message =~ m,\b{wb}s/([^/]+)/([^/]*)(?:/([gi]*))?,) {
    132 		# chat s///
    133 		my $prev_message = $subbuffer->{$sender_nick};
    134 		my $retext = $1;
    135 		my $repl = $2;
    136 		my $mods = $3 // '';
    137 		my $regex;
    138 		my $imod = $mods =~ /i/ ? 'i' : '';
    139 		eval {
    140 			$regex = qr/(?$imod:$retext)/ 
    141 		};
    142 		return $prev_message, '' if $@;
    143 		my $ismatch;
    144 		if ($mods =~ /g/) {
    145 			$ismatch = $prev_message =~ s/$regex/$repl/g;
    146 		} else {
    147 			$ismatch = $prev_message =~ s/$regex/$repl/;
    148 		}
    149 		if ($ismatch) {
    150 			$reply = "${sender_nick} meant to say: ${prev_message}"
    151 		}
    152 		return $prev_message, $reply;
    153 	} elsif ($message =~ m,watch\?v=([a-zA-Z0-9_-]+),) {
    154 		# post youtube video titles from video ID
    155 		my $video_id = $1;
    156 		my $response = HTTP::Tiny->new->get("https://fuyt.lab8.cz/?s=${video_id}&o=relevance");
    157 		unless ($response->{success}) {
    158 			$reply = "failed to get title of youtube video ${video_id} ${MATA_DEAD} ($response->{status} $response->{reason}!)";
    159 			return $message, $reply;
    160 		}
    161 		unless (length $response->{content}) {
    162 			$reply = "failed to get title of youtube video ${video_id} ${MATA_DEAD} (HTTP response empty!)";
    163 			return $message, $reply;
    164 		}
    165 		my $content = $response->{content};
    166 		if ($content =~ m,<span class="title"><a href="https://www\.youtube\.com/watch\?v=${video_id}" accesskey="0">([^<]+)</a>,i) {
    167 			my $title = $1;
    168 			$title =~ tr/[\000\r\n]//d;
    169 			$title = trim($title);
    170 			$reply = "YouTube: ${title}";
    171 		} else {
    172 			$reply = "failed to get title of video ${video_id} ${MATA_DEAD} (no videos matching ID ${video_id}!)";
    173 		}
    174 	} elsif ($message =~ m,https?://[^ ]+,) {
    175 		my $url = $&;
    176 		# get in it's HEAD to check if it's text/html
    177 		my $response = HTTP::Tiny->new->head($url);
    178 		unless ($response->{success}) {
    179 			$reply = "$response->{status} $response->{reason} (HEAD ${url}) ${MATA_DEAD}";
    180 			return $message, $reply;
    181 		}
    182 		unless ($response->{headers}->{'content-type'}) {
    183 			$reply = "no content-type header in HTTP response (HEAD ${url}) ${MATA_DEAD}";
    184 			return $message, $reply;
    185 		}
    186 		unless ($response->{headers}->{'content-type'} =~ m,text/html,) {
    187 			# we got a non text/html content type
    188 			$reply = "File: $response->{headers}->{'content-type'}";
    189 			return $message, $reply;
    190 		}
    191 
    192 		# if it's text/html, GET it's title
    193 		$response = HTTP::Tiny->new->get($url);
    194 		unless ($response->{success}) {
    195 			$reply = "$response->{status} $response->{reason} (GET ${url}) ${MATA_DEAD}";
    196 			return $message, $reply;
    197 		}
    198 		unless (length $response->{content}) {
    199 			$reply = "Empty HTTP response (GET ${url}) ${MATA_DEAD}";
    200 			return $message, $reply;
    201 		}
    202 		my $content = $response->{content};
    203 		if ($content =~ m,<title[^>]*>([^<]+)</title[^>]*>,i) {
    204 			my $title = $1;
    205 			$title =~ tr/[\000\r\n]//d;
    206 			$title = trim($title);
    207 			$reply = "Title: ${title}";
    208 		} else {
    209 			$reply = "No title found (GET ${url} ${MATA_DEAD}";
    210 		}
    211 	# TODO: this part should use [^ ] and .* instead but i'm scared of .*
    212 	} elsif ($message =~ /^ *${NICK_RE}[:, ] *([^\000\r\n ][^\000\r\n]*)$/
    213 		or $message =~ /^ *([^\000\r\n ][^\000\r\n]*)[, ] *${NICK_RE}[\W\d]*$/) {
    214 		$reply = respond_command($lists->{'ball'}, $lists->{'hellos'}, $sender_nick, $1);
    215 	} elsif ($message =~ /\b${NICK_RE}\b/) {
    216 		$reply = respond_mention($lists->{'quotes'}, $sender_nick, $message);
    217 	}
    218 	return $message, $reply;
    219 }
    220 
    221 sub logger {
    222 	my ($opts, $level, $message) = @_;
    223 	return if not $opts->{'logging'};
    224 	if ($level eq 'error') {
    225 		return if not $opts->{'loglevel'} =~ /^(?:error|info)$/;
    226 		$message = '!! ' . $message;
    227 	} elsif ($level eq 'info') {
    228 		return if $opts->{'loglevel'} ne 'info';
    229 	} else {
    230 		die "logger: invalid logging level '${level}'!";
    231 	}
    232 	$message = strftime('%Y-%m-%dT%H:%M:%SZ ', gmtime()) . $message . "\n";
    233 	print STDERR $message;
    234 }
    235 
    236 sub out {
    237 	my ($sock, $opts, $message) = @_;
    238 	my $s = IO::Select->new($sock);
    239 	$! = 0;
    240 	if ($s->can_write($SOCK_TIMEOUT)) {
    241 		print $sock $message . "\r\n";
    242 		logger($opts, 'info', '<- ' . $message);
    243 		return 1;
    244 	} else {
    245 		if ($!) { logger($opts, 'error', $!); return; }
    246 		return 0;
    247 	}
    248 }
    249 
    250 sub evasdrop {
    251 	my ($sock, $opts, $lists, $subbuffer) = @_;
    252 	my $buffer = '';
    253 	my $checkping = 1;
    254 	my $chunk = '';
    255 	my $message = '';
    256 	my $pingtime = time;
    257 	my $pongtime = time;
    258 	my $s = IO::Select->new($sock);
    259 	my %subbuffer;
    260 
    261 	while (1) {
    262 		my $reply;
    263 		$! = 0;
    264 		if ($s->can_read($SOCK_TIMEOUT)) {
    265 			# buffer up
    266 			if ($opts->{'tls'}) {
    267 				$chunk = <$sock>;
    268 			} else {
    269 				$sock->recv($chunk, $CHUNK_LENGTH);
    270 			}
    271 			if (not $chunk) {
    272 				logger($opts, 'error', 'recv received an empty response');
    273 				return;
    274 			}
    275 			return if not $chunk;
    276 			$chunk =~ /^([^\r\n]+)(\r\n)?([^\r\n]+)?$/;
    277 			# keep reading if chunk is empty
    278 			next if (not $1);
    279 			# if chunks isn't empty, check for framing point
    280 			if ($2) {
    281 				# if we found a framing point, flush buffer and text till framing point
    282 				$message = $buffer . $1;
    283 				if ($3) {
    284 					# if we have text after framing, make it the new content of buffer
    285 					$buffer = $3;
    286 				} else {
    287 					# if we have no text after framing, clear buffer
    288 					$buffer = '';
    289 				}
    290 			} else {
    291 				# if there's no framing. append chunk to end of buffer and keep reading
    292 				$buffer .= $chunk;
    293 				next;
    294 			}
    295 			logger($opts, 'info', '-> ' . $message);
    296 
    297 			# respond to message
    298 			if ($message =~ /^PING :([^\000\r\n\ ]+)$/) {
    299 				# if we got a ping, pong back
    300 				$reply = "PONG :$1";
    301 			} elsif ($message =~ /^:[^\000\r\n ]+ PONG/) {
    302 				# if server ponged us back, reset ping-pong and allow pinging again
    303 				$pingtime = time;
    304 				$pongtime = time;
    305 				$checkping = 1;
    306 			} elsif ($message =~ /^:([^\000\r\n\#\&\ ][^\000\r\n\ ]*)![^\000\r\n\ ]+@[^\000\r\n\ ]+ PRIVMSG $opts->{'chan'} :([^\000\r\n]*)$/) {
    307 				# if we got a message to our chan. read and act accordingly
    308 				my $sender_nick = $1;
    309 				my $sender_message = $2;
    310 				($subbuffer->{$sender_nick}, $reply) = respond($lists, $subbuffer, $sender_nick, $sender_message);
    311 				$reply = "PRIVMSG $opts->{'chan'} :${reply}" if $reply;
    312 			}
    313 			out($sock, $opts, $reply) if $reply;
    314 		} else {
    315 			if ($!) { logger($opts, 'error', $!); return; }
    316 		}
    317 			
    318 		# ping-pong
    319 		if ($checkping and time-$pingtime >= $PING_TIMEOUT) {
    320 			# ping server every once in a while and wait for pong
    321 			out($sock, $opts, "PING $opts->{'host'}");
    322 			$checkping = 0;
    323 		} elsif (not $checkping and time-$pongtime >= $PONG_TIMEOUT) {
    324 			# we leaving if we don't get ponged on time
    325 			logger($opts, 'error', 'PONG response from server timed out');
    326 			return
    327 		}
    328 	}
    329 }
    330 
    331 sub init {
    332 	my $ball_path = $DEFAULT_PATH_BALL;
    333 	my $chan = $DEFAULT_CHAN;
    334 	my $hellos_path = $DEFAULT_PATH_HELLOS;
    335 	my $host = $DEFAULT_HOST;
    336 	my $logging = $DEFAULT_LOGGING;
    337 	my $loglevel = $DEFAULT_LOGLEVEL;
    338 	my $port = $DEFAULT_PORT;
    339 	my $quotes_path = $DEFAULT_PATH_QUOTES;
    340 	my $tls = $DEFAULT_TLS;
    341 
    342 	getopts('tlvH:b:h:j:p:q:', \my %flags);
    343 	$ball_path = $flags{'b'} if $flags{'b'};
    344 	$chan = "#$flags{'j'}" if $flags{'j'};
    345 	$hellos_path = $flags{'H'} if $flags{'H'};
    346 	$host = $flags{'h'} if $flags{'h'};
    347 	$port = $flags{'p'} if $flags{'p'};
    348 	$quotes_path = $flags{'q'} if $flags{'q'};
    349 	$tls = 1 if $flags{'t'};
    350 	if ($flags{'l'}) {
    351 		$logging = 1;
    352 		$loglevel = 'error';
    353 	}
    354 	$loglevel = 'info' if $flags{'v'};
    355 	open (my $ball_file, '<', $ball_path) or die "couldn't open ${ball_path}: $!";
    356 	chomp(my @ball = <$ball_file>);
    357 	close $ball_file or die "${ball_file}: $!";
    358 	open (my $hellos_file, '<', $hellos_path) or die "couldn't open ${hellos_path}: $!";
    359 	chomp(my @hellos = <$hellos_file>);
    360 	close $hellos_file or die "${hellos_file}: $!";
    361 	open (my $quotes_file, '<', $quotes_path) or die "couldn't open ${quotes_path}: $!";
    362 	chomp(my @quotes = <$quotes_file>);
    363 	close $quotes_file or die "${quotes_file}: $!";
    364 
    365 	return {
    366 		chan => $chan,	
    367 		host => $host,
    368 		logging => $logging,
    369 		loglevel => $loglevel,
    370 		port => $port,
    371 		tls => $tls,
    372 	},
    373 	{
    374 		ball => \@ball,
    375 		hellos => \@hellos,
    376 		quotes => \@quotes,
    377 	};
    378 }
    379 
    380 my ($opts, $lists) = init();
    381 while (1) {
    382 	# start the connection
    383 	my $sock;
    384 	if ($opts->{'tls'}) {
    385 		if (not $sock = IO::Socket::SSL->new(
    386 			Domain => AF_INET,
    387 			Timeout => $CONNECT_TIMEOUT,
    388 			Type => SOCK_STREAM,
    389 			PeerHost => $opts->{'host'},
    390 			PeerPort => $opts->{'port'},
    391 		)) {
    392 			logger($opts, 'error', "can't open socket: $IO::Socket::errstr");
    393 			$sock = undef;
    394 		}
    395 	} else {
    396 		if (not $sock = IO::Socket->new(
    397 			Domain => AF_INET,
    398 			Timeout => $CONNECT_TIMEOUT,
    399 			Type => SOCK_STREAM,
    400 			proto => $opts->{'tcp'},
    401 			PeerHost => $opts->{'host'},
    402 			PeerPort => $opts->{'port'},
    403 		)) {
    404 			logger($opts, 'error', "can't open socket: $IO::Socket::errstr");
    405 			$sock = undef;
    406 		}
    407 	}
    408 	if ($sock) {
    409 		# set user, real, and nick, then join
    410 		out($sock, $opts, "USER ${USER} * * :${REAL}");
    411 		out($sock, $opts, "NICK ${NICK}");
    412 		out($sock, $opts, "JOIN $opts->{'chan'}");
    413 		# main loop
    414 		evasdrop($sock, $opts, $lists);
    415 		# end session and sleep a bit before reconnecting
    416 		out($sock, $opts, 'QUIT');
    417 		if ($opts->{'tls'}) {
    418 			close($sock);
    419 		} else {
    420 			$sock->close();
    421 		}
    422 	}
    423 	logger($opts, 'error', "reconnecting in ${RECONNECT_TIME} seconds...");
    424 	sleep $RECONNECT_TIME;
    425 }