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 }