3 our $VERSION = '0.4.2 svn $Revision$';
5 # Copyright (c) 2008 Rudolf "divVerent" Polzer
7 # Permission is hereby granted, free of charge, to any person
8 # obtaining a copy of this software and associated documentation
9 # files (the "Software"), to deal in the Software without
10 # restriction, including without limitation the rights to use,
11 # copy, modify, merge, publish, distribute, sublicense, and/or sell
12 # copies of the Software, and to permit persons to whom the
13 # Software is furnished to do so, subject to the following
16 # The above copyright notice and this permission notice shall be
17 # included in all copies or substantial portions of the Software.
19 # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
20 # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
21 # OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
22 # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
23 # HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
24 # WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
25 # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
26 # OTHER DEALINGS IN THE SOFTWARE.
30 # $conn->sockname() returns a connection type specific representation
31 # string of the local address, or undef if not applicable.
32 # $conn->send("string") sends something over the connection.
33 # $conn->recv() receives a string from the connection, or returns "" if no
35 # $conn->fds() returns all file descriptors used by the connection, so one
36 # can use select() on them.
38 # Usually wraps around a connection and implements a command based
39 # structure over it. It usually is constructed using new
40 # ChannelType($connection, someparameters...)
41 # @cmds = $chan->join_commands(@cmds) joins multiple commands to a single
42 # command string if the protocol supports it, or does nothing and leaves
43 # @cmds unchanged if the protocol does not support that usage (this is
44 # meant to save send() invocations).
45 # $chan->send($command, $nothrottle) sends a command over the channel. If
46 # $nothrottle is sent, the command must not be left out even if the channel
47 # is saturated (for example, because of IRC's flood control mechanism).
48 # $chan->quote($str) returns a string in a quoted form so it can safely be
49 # inserted as a substring into a command, or returns $str as is if not
50 # applicable. It is assumed that the result of the quote method is used
51 # as part of a quoted string, if the protocol supports that.
52 # $chan->recv() returns a list of received commands from the channel, or
53 # the empty list if none are available.
54 # $conn->fds() returns all file descriptors used by the channel's
55 # connections, so one can use select() on them.
64 # Represents a connection over a socket.
65 # Mainly used to wrap a channel around it for, in this case, line based or rcon-like operation.
66 package Connection::Socket;
73 # my $conn = new Connection::Socket(tcp => "localaddress" => "remoteaddress" => 6667);
74 # If the remote address does not contain a port number, the numeric port is
75 # used (it serves as a default port).
78 my ($class, $proto, $local, $remote, $defaultport) = @_;
79 my $sock = IO::Socket::INET->new(
81 (length($local) ? (LocalAddr => $local) : ()),
83 PeerPort => $defaultport
84 ) or die "socket $proto/$local/$remote: $!";
87 # Mortal fool! Release me from this wretched tomb! I must be set free
88 # or I will haunt you forever! I will hide your keys beneath the
89 # cushions of your upholstered furniture... and NEVERMORE will you be
90 # able to find socks that match!
92 # My demonic powers have made me OMNIPOTENT! Bwahahahahahahaha!
95 bless $you, 'Connection::Socket';
98 # $sock->sockname() returns the local address of the socket.
102 my ($port, $addr) = sockaddr_in $self->{sock}->sockname();
103 return "@{[inet_ntoa $addr]}:$port";
106 # $sock->send($data) sends some data over the socket; on success, 1 is returned.
109 my ($self, $data) = @_;
112 if(not eval { $self->{sock}->send($data); })
120 # $sock->recv() receives as much as possible from the socket (or at most 32k). Returns "" if no data is available.
125 if(defined $self->{sock}->recv($data, 32768, 0))
139 # $sock->fds() returns the socket file descriptor.
143 return fileno $self->{sock};
152 # Line-based buffered connectionless FIFO channel.
153 # Whatever is sent to it using send() is echoed back when using recv().
154 package Channel::FIFO;
159 # my $chan = new Channel::FIFO();
167 bless $you, 'Channel::FIFO';
170 sub join_commands($@)
172 my ($self, @data) = @_;
178 my ($self, $line, $nothrottle) = @_;
179 push @{$self->{buffer}}, $line;
184 my ($self, $data) = @_;
191 my $r = $self->{buffer};
192 $self->{buffer} = [];
208 # QW rcon protocol channel.
209 # Wraps around a UDP based Connection and sends commands as rcon commands as
210 # well as receives rcon replies. The quote and join_commands methods are using
211 # DarkPlaces engine specific rcon protocol extensions.
217 # my $chan = new Channel::QW($connection, "password");
220 my ($class, $conn, $password) = @_;
223 password => $password,
227 bless $you, 'Channel::QW';
230 # Note: multiple commands in one rcon packet is a DarkPlaces extension.
231 sub join_commands($@)
233 my ($self, @data) = @_;
234 return join "\0", @data;
239 my ($self, $line, $nothrottle) = @_;
240 return $self->{connector}->send("\377\377\377\377rcon $self->{password} $line");
243 # Note: backslash and quotation mark escaping is a DarkPlaces extension.
246 my ($self, $data) = @_;
247 $data =~ s/[\000-\037]//g;
248 $data =~ s/([\\"])/\\$1/g;
249 $data =~ s/\$/\$\$/g;
258 my $s = $self->{connector}->recv();
264 if $s !~ /^\377\377\377\377n(.*)$/s;
265 $self->{recvbuf} .= $1;
268 while($self->{recvbuf} =~ s/^(.*?)(?:\r\n?|\n)//)
278 return $self->{connector}->fds();
287 # Line based protocol channel.
288 # Wraps around a TCP based Connection and sends commands as text lines
289 # (separated by CRLF). When reading responses from the Connection, any type of
290 # line ending is accepted.
291 # A flood control mechanism is implemented.
292 package Channel::Line;
295 use Time::HiRes qw/time/;
298 # my $chan = new Channel::Line($connection);
301 my ($class, $conn) = @_;
311 bless $you, 'Channel::Line';
314 sub join_commands($@)
316 my ($self, @data) = @_;
320 # Sets new flood control parameters:
321 # $chan->throttle(maximum lines per second, maximum burst length allowed to
322 # exceed the lines per second limit);
323 # RFC 1459 describes these parameters to be 0.5 and 5 for the IRC protocol.
324 # If the $nothrottle flag is set while sending, the line is sent anyway even
325 # if flooding would take place.
328 my ($self, $linepersec, $maxlines) = @_;
329 $self->{linepersec} = $linepersec;
330 $self->{maxlines} = $maxlines;
331 $self->{capacity} = $maxlines;
336 my ($self, $line, $nothrottle) = @_;
338 if(defined $self->{capacity})
340 $self->{capacity} += ($t - $self->{lastsend}) * $self->{linepersec};
341 $self->{lastsend} = $t;
342 $self->{capacity} = $self->{maxlines}
343 if $self->{capacity} > $self->{maxlines};
347 if $self->{capacity} < 0;
349 $self->{capacity} -= 1;
352 return $self->{connector}->send("$line\r\n");
357 my ($self, $data) = @_;
358 $data =~ s/\r\n?/\n/g;
368 my $s = $self->{connector}->recv();
373 $self->{recvbuf} .= $s;
376 while($self->{recvbuf} =~ s/^(.*?)(?:\r\n?|\n)//)
386 return $self->{connector}->fds();
394 # main program... a gateway between IRC and DarkPlaces servers
401 use Time::HiRes qw/time/;
403 our @handlers = (); # list of [channel, expression, sub to handle result]
404 our @tasks = (); # list of [time, sub]
408 playernick_0 => "(console)",
414 irc_channel => undef,
415 irc_ping_delay => 120,
417 irc_nickserv_password => "",
418 irc_nickserv_identify => 'PRIVMSG NickServ :IDENTIFY %2$s',
419 irc_nickserv_ghost => 'PRIVMSG NickServ :GHOST %1$s %2$s',
420 irc_nickserv_ghost_attempts => 3,
422 irc_quakenet_authname => "",
423 irc_quakenet_password => "",
424 irc_quakenet_getchallenge => 'PRIVMSG Q@CServe.quakenet.org :CHALLENGE',
425 irc_quakenet_challengeauth => 'PRIVMSG Q@CServe.quakenet.org :CHALLENGEAUTH',
426 irc_quakenet_challengeprefix => ':Q!TheQBot@CServe.quakenet.org NOTICE [^:]+ :CHALLENGE',
430 dp_password => undef,
431 dp_status_delay => 30,
432 dp_server_from_wan => "",
439 # MISC STRING UTILITY ROUTINES to convert between DarkPlaces and IRC conventions
441 # convert mIRC color codes to DP color codes
442 our @color_irc2dp_table = (7, 0, 4, 2, 1, 1, 6, 1, 3, 2, 5, 5, 4, 6, 7, 7);
443 our @color_dp2irc_table = (14, 4, 9, 8, 12, 11, 13, 14, 15, 15); # not accurate, but legible
444 our @color_dp2ansi_table = ("m", "1;31m", "1;32m", "1;33m", "1;34m", "1;36m", "1;35m", "m", "1m", "1m"); # not accurate, but legible
445 our %color_team2dp_table = (5 => 1, 14 => 4, 13 => 3, 10 => 6);
446 our %color_team2irc_table = (5 => 4, 14 => 12, 13 => 8, 10 => 13);
450 $message =~ s/\^/^^/g;
452 $message =~ s{\003(\d\d?)(?:,(\d?\d?))?|(\017)}{
453 # $1 is FG, $2 is BG, but let's ignore BG
454 my $oldcolor = $color;
461 $color = $color_irc2dp_table[$1];
462 $color = $oldcolor if not defined $color;
464 ($color == $oldcolor) ? '' : '^' . $color;
466 $message =~ s{[\000-\037]}{}gs; # kill bold etc. for now
470 our @text_qfont_table = ( # ripped from DP console.c qfont_table
471 "\0", '#', '#', '#', '#', '.', '#', '#',
472 '#', 9, 10, '#', ' ', 13, '.', '.',
473 '[', ']', '0', '1', '2', '3', '4', '5',
474 '6', '7', '8', '9', '.', '<', '=', '>',
475 ' ', '!', '"', '#', '$', '%', '&', '\'',
476 '(', ')', '*', '+', ',', '-', '.', '/',
477 '0', '1', '2', '3', '4', '5', '6', '7',
478 '8', '9', ':', ';', '<', '=', '>', '?',
479 '@', 'A', 'B', 'C', 'D', 'E', 'F', 'G',
480 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O',
481 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W',
482 'X', 'Y', 'Z', '[', '\\', ']', '^', '_',
483 '`', 'a', 'b', 'c', 'd', 'e', 'f', 'g',
484 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o',
485 'p', 'q', 'r', 's', 't', 'u', 'v', 'w',
486 'x', 'y', 'z', '{', '|', '}', '~', '<',
487 '<', '=', '>', '#', '#', '.', '#', '#',
488 '#', '#', ' ', '#', ' ', '>', '.', '.',
489 '[', ']', '0', '1', '2', '3', '4', '5',
490 '6', '7', '8', '9', '.', '<', '=', '>',
491 ' ', '!', '"', '#', '$', '%', '&', '\'',
492 '(', ')', '*', '+', ',', '-', '.', '/',
493 '0', '1', '2', '3', '4', '5', '6', '7',
494 '8', '9', ':', ';', '<', '=', '>', '?',
495 '@', 'A', 'B', 'C', 'D', 'E', 'F', 'G',
496 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O',
497 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W',
498 'X', 'Y', 'Z', '[', '\\', ']', '^', '_',
499 '`', 'a', 'b', 'c', 'd', 'e', 'f', 'g',
500 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o',
501 'p', 'q', 'r', 's', 't', 'u', 'v', 'w',
502 'x', 'y', 'z', '{', '|', '}', '~', '<'
507 $message = join '', map { $text_qfont_table[ord $_] } split //, $message;
514 $message =~ s{\^(.)(?=([0-9,]?))}{
517 $c =~ /^[0-9]$/ ? '' : "^$c";
519 return text_dp2ascii $message;
526 $message =~ s{\^(.)(?=([0-9,]?))}{
530 $c =~ /^[0-9]$/ ? do {
531 my $oldcolor = $color;
532 $c = 0 if $c >= 7; # map 0, 7, 8, 9 to default (no bright white or such stuff)
533 $color = $color_dp2irc_table[$c];
534 ($color == $oldcolor) ? '' :
536 $f eq ',' ? "\0003$color\0002\0002" :
537 $f ne '' ? sprintf "\0003%02d", $color : "\0003$color";
540 $message = text_dp2ascii $message;
541 $message =~ s/\0001/\017/g;
542 $message =~ s/\0002/\002/g;
543 $message =~ s/\0003/\003/g;
551 $message =~ s{\^(.)}{
554 $c =~ /^[0-9]$/ ? do {
555 my $oldcolor = $color;
556 $color = $color_dp2ansi_table[$c];
557 ($color eq $oldcolor) ? '' :
561 $message = text_dp2ascii $message;
562 $message =~ s/\000/\033/g;
569 # if the message ends with an odd number of ^, kill one
570 chop $message if $message =~ /(?:^|[^\^])\^(\^\^)*$/;
576 # Nexuiz specific parsing of some server messages
578 sub nex_is_teamplay($)
581 return $map =~ /^(?:kh|ctf|tdm|dom)_/;
584 sub nex_slotsstring()
587 if(defined $store{slots_max})
589 my $slots = $store{slots_max} - $store{slots_active};
590 my $slots_s = ($slots == 1) ? '' : 's';
591 $slotsstr = " ($slots free slot$slots_s)";
592 my $s = $config{dp_server_from_wan} || $config{dp_server};
593 $slotsstr .= "; join now: \002nexuiz +connect $s"
594 if $slots >= 1 and not $store{lms_blocked};
601 # Do we have a config file? If yes, read and parse it (syntax: key = value
602 # pairs, separated by newlines), if not, complain.
603 die "Usage: $0 configfile\n"
606 open my $fh, "<", $ARGV[0]
607 or die "open $ARGV[0]: $!";
612 /^(.*?)\s+=(?:\s+(.*))?$/ or next;
613 warn "Undefined config item: $1"
614 unless exists $config{$1};
615 $config{$1} = defined $2 ? $2 : "";
618 my @missing = grep { !defined $config{$_} } keys %config;
619 die "The following config items are missing: @missing"
624 # Create a channel for error messages and other internal status messages...
626 $channels{system} = new Channel::FIFO();
628 # for example, quit messages caused by signals (if SIGTERM or SIGINT is first
629 # received, try to shut down cleanly, and if such a signal is received a second
633 exit 1 if $quitting++;
634 $channels{system}->send("quit SIGINT");
637 exit 1 if $quitting++;
638 $channels{system}->send("quit SIGTERM");
643 # Create the two channels to gateway between...
645 $channels{irc} = new Channel::Line(new Connection::Socket(tcp => "" => $config{irc_server} => 6667));
646 $channels{dp} = new Channel::QW(my $dpsock = new Connection::Socket(udp => $config{dp_listen} => $config{dp_server} => 26000), $config{dp_password});
647 $config{dp_listen} = $dpsock->sockname();
648 print "Listening on $config{dp_listen}\n";
650 $channels->{irc}->throttle(0.5, 5);
653 # Utility routine to write to a channel by name, also outputting what's been written and some status
657 my $nothrottle = shift;
658 my $chan = $channels{$chanstr};
661 print "UNDEFINED: $chanstr, ignoring message\n";
664 @_ = $chan->join_commands(@_);
667 my $result = $chan->send($_, $nothrottle);
670 print " $chanstr << $_\n";
674 print "FLOOD: $chanstr << $_\n";
678 print "ERROR: $chanstr << $_\n";
679 $channels{system}->send("error $chanstr", 0);
686 # Schedule a task for later execution by the main loop; usage: schedule sub {
687 # task... }, $time; When a scheduled task is run, a reference to the task's own
688 # sub is passed as first argument; that way, the task is able to re-schedule
689 # itself so it gets periodically executed.
692 my ($sub, $time) = @_;
693 push @tasks, [time() + $time, $sub];
696 # Build up an IO::Select object for all our channels.
697 my $s = IO::Select->new();
698 for my $chan(values %channels)
700 $s->add($_) for $chan->fds();
703 # On IRC error, delete some data store variables of the connection, and
704 # reconnect to the IRC server soon (but only if someone is actually playing)
707 # prevent multiple instances of this timer
708 return if $store{irc_error_active};
709 $store{irc_error_active} = 1;
711 delete $channels{irc};
714 if(!defined $store{slots_full})
716 # DP is not running, then delay IRC reconnecting
717 #use Data::Dumper; print Dumper \$timer;
718 schedule $timer => 1;;
720 # this will keep irc_error_active
722 $channels{irc} = new Channel::Line(new Connection::Socket(tcp => "" => $config{irc_server}));
723 delete $store{$_} for grep { /^irc_/ } keys %store;
724 $store{irc_nick} = "";
727 out dp => 0, 'status', 'log_dest_udp';
729 # this will clear irc_error_active
734 # IRC joining (if this is called as response to a nick name collision, $is433 is set);
735 # among other stuff, it performs NickServ or Quakenet authentication. This is to be called
736 # until the channel has been joined for every message that may be "interesting" (basically,
737 # IRC 001 hello messages, 443 nick collision messages and some notices by services).
743 if $store{irc_joined_channel};
745 #use Data::Dumper; print Dumper \%store;
749 if(length $store{irc_nick})
751 # we already have another nick, but couldn't change to the new one
752 # try ghosting and then get the nick again
753 if(length $config{irc_nickserv_password})
755 if(++$store{irc_nickserv_ghost_attempts} <= $config{irc_nickserv_ghost_attempts})
757 $store{irc_nick_requested} = $config{irc_nick};
758 out irc => 1, sprintf($config{irc_nickserv_ghost}, $config{irc_nick}, $config{irc_nickserv_password});
760 out irc => 1, "NICK $config{irc_nick}";
762 return; # we'll get here again for the NICK success message, or for a 433 failure
764 # otherwise, we failed to ghost and will continue with the wrong
765 # nick... also, no need to try to identify here
767 # otherwise, we can't handle this and will continue with our wrong nick
771 # we failed to get an initial nickname
772 # change ours a bit and try again
773 if(length $store{irc_nick_requested} < 9)
775 $store{irc_nick_requested} .= '_';
779 substr $store{irc_nick_requested}, int(rand length $store{irc_nick_requested}), 1, chr(97 + int rand 26);
781 out irc => 1, "NICK $store{irc_nick_requested}";
782 return; # when it fails, we'll get here again, and when it succeeds, we will continue
786 # we got a 001 or a NICK message, so $store{irc_nick} has been updated
787 if(length $config{irc_nickserv_password})
789 if($store{irc_nick} eq $config{irc_nick})
792 out irc => 1, sprintf($config{irc_nickserv_identify}, $config{irc_nick}, $config{irc_nickserv_password});
797 if(++$store{irc_nickserv_ghost_attempts} <= $config{irc_nickserv_ghost_attempts})
799 $store{irc_nick_requested} = $config{irc_nick};
800 out irc => 1, sprintf($config{irc_nickserv_ghost}, $config{irc_nick}, $config{irc_nickserv_password});
802 out irc => 1, "NICK $config{irc_nick}";
804 return; # we'll get here again for the NICK success message, or for a 433 failure
806 # otherwise, we failed to ghost and will continue with the wrong
807 # nick... also, no need to try to identify here
811 # we are on Quakenet. Try to authenticate.
812 if(length $config{irc_quakenet_password} and length $config{irc_quakenet_authname})
814 if(defined $store{irc_quakenet_challenge})
816 if($store{irc_quakenet_challenge} =~ /^MD5 (.*)/)
818 out irc => 1, "$config{irc_quakenet_challengeauth} $config{irc_quakenet_authname} " . Digest::MD5::md5_hex("$config{irc_quakenet_password} $1");
823 out irc => 1, $config{irc_quakenet_getchallenge};
825 # we get here again when Q asks us
829 # if we get here, we are on IRC
830 $store{irc_joined_channel} = 1;
832 out irc => 1, "JOIN $config{irc_channel}";
839 # List of all handlers on the various sockets. Additional handlers can be added by a plugin.
841 # detect a server restart and set it up again
842 [ dp => q{ *(?:Warning: Could not expand \$|Unknown command ")(?:rcon2irc_[a-z0-9_]*)[" ]*} => sub {
844 'alias rcon2irc_eval "$*"',
846 'sv_logscores_console 0',
847 'sv_logscores_bots 1',
849 'sv_eventlog_console 1',
850 'alias rcon2irc_say_as "set say_as_restorenick \"$sv_adminnick\"; sv_adminnick \"$1^3\"; say \"^7$2\"; rcon2irc_say_as_restore"',
851 'alias rcon2irc_say_as_restore "set sv_adminnick \"$say_as_restorenick\""',
852 'alias rcon2irc_quit "echo \"quitting rcon2irc $1: log_dest_udp is $log_dest_udp\""'; # note: \\\\\\" ->perl \\\" ->console \"
856 # detect missing entry in log_dest_udp and fix it
857 [ dp => q{"log_dest_udp" is "([^"]*)" \["[^"]*"\]} => sub {
859 my @dests = split ' ', $dest;
860 return 0 if grep { $_ eq $config{dp_listen} } @dests;
861 out dp => 0, 'log_dest_udp "' . join(" ", @dests, $config{dp_listen}) . '"';
865 # retrieve hostname from status replies
866 [ dp => q{host: (.*)} => sub {
868 $store{dp_hostname} = $name;
872 # retrieve version from status replies
873 [ dp => q{version: (.*)} => sub {
875 $store{dp_version} = $version;
879 # retrieve number of open player slots
880 [ dp => q{players: (\d+) active \((\d+) max\)} => sub {
881 my ($active, $max) = @_;
882 my $full = ($active >= $max);
883 $store{slots_max} = $max;
884 $store{slots_active} = $active;
885 if($full != ($store{slots_full} || 0))
887 $store{slots_full} = $full;
889 if $store{lms_blocked};
892 out irc => 0, "PRIVMSG $config{irc_channel} :\001ACTION is full!\001";
896 my $slotsstr = nex_slotsstring();
897 out irc => 0, "PRIVMSG $config{irc_channel} :\001ACTION can be joined again$slotsstr!\001";
903 # LMS: detect "no more lives" message
904 [ dp => q{\^4.*\^4 has no more lives left} => sub {
905 if(!$store{lms_blocked})
907 $store{lms_blocked} = 1;
908 if(!$store{slots_full})
911 if($store{lms_blocked})
913 out irc => 0, "PRIVMSG $config{irc_channel} :\001ACTION can't be joined until next round (a player has no more lives left)\001";
920 # detect IRC errors and reconnect
921 [ irc => q{ERROR .*} => \&irc_error ],
922 [ system => q{error irc} => \&irc_error ],
925 [ irc => q{:[^ ]* 433 .*} => sub {
926 return irc_joinstage(433);
930 [ irc => q{:[^ ]* 001 .*} => sub {
931 $store{irc_seen_welcome} = 1;
932 $store{irc_nick} = $store{irc_nick_requested};
933 return irc_joinstage(0);
936 # IRC my nickname changed
937 [ irc => q{:(?i:(??{$store{irc_nick}}))![^ ]* (?i:NICK) :(.*)} => sub {
939 $store{irc_nick} = $n;
940 return irc_joinstage(0);
943 # Quakenet: challenge from Q
944 [ irc => q{(??{$config{irc_quakenet_challengeprefix}}) (.*)} => sub {
945 $store{irc_quakenet_challenge} = $1;
946 return irc_joinstage(0);
949 # shut down everything on SIGINT
950 [ system => q{quit (.*)} => sub {
952 out irc => 1, "QUIT :$cause";
953 $store{quitcookie} = int rand 1000000000;
954 out dp => 0, "rcon2irc_quit $store{quitcookie}";
957 # remove myself from the log destinations and exit everything
958 [ dp => q{quitting rcon2irc (??{$store{quitcookie}}): log_dest_udp is (.*) *} => sub {
960 my @dests = grep { $_ ne $config{dp_listen} } split ' ', $dest;
961 out dp => 0, 'log_dest_udp "' . join(" ", @dests) . '"';
967 [ irc => q{PING (.*)} => sub {
969 out irc => 1, "PONG $data";
974 [ irc => q{:[^ ]* PONG .* :(.*)} => sub {
977 if not defined $store{irc_pingtime};
979 if $data ne $store{irc_pingtime};
980 print "* measured IRC line delay: @{[time() - $store{irc_pingtime}]}\n";
981 undef $store{irc_pingtime};
985 # detect channel join message and note hostname length to get the maximum allowed line length
986 [ irc => q{(:(?i:(??{$store{irc_nick}}))![^ ]* )(?i:JOIN) :(?i:(??{$config{irc_channel}}))} => sub {
987 $store{irc_maxlen} = 510 - length($1);
988 $store{irc_joined_channel} = 1;
989 print "* detected maximum line length for channel messages: $store{irc_maxlen}\n";
993 # chat: Nexuiz server -> IRC channel
994 [ dp => q{\001(.*?)\^7: (.*)} => sub {
995 my ($nick, $message) = map { color_dp2irc $_ } @_;
996 out irc => 0, "PRIVMSG $config{irc_channel} :<$nick\017> $message";
1000 # chat: Nexuiz server -> IRC channel, nick set
1001 [ dp => q{:join:(\d+):(?:player|bot):(.*)} => sub {
1002 my ($id, $nick) = @_;
1003 $nick = color_dp2irc $nick;
1004 $store{"playernick_$id"} = $nick;
1008 # chat: Nexuiz server -> IRC channel, nick change/set
1009 [ dp => q{:name:(\d+):(.*)} => sub {
1010 my ($id, $nick) = @_;
1011 $nick = color_dp2irc $nick;
1012 my $oldnick = $store{"playernick_$id"};
1013 out irc => 0, "PRIVMSG $config{irc_channel} :* $oldnick\017 is now known as $nick";
1014 $store{"playernick_$id"} = $nick;
1018 # chat: Nexuiz server -> IRC channel, vote call
1019 [ dp => q{:vote:vcall:(\d+):(.*)} => sub {
1020 my ($id, $command) = @_;
1021 $command = color_dp2irc $command;
1022 my $oldnick = $store{"playernick_$id"};
1023 out irc => 0, "PRIVMSG $config{irc_channel} :* $oldnick\017 calls a vote for \"$command\017\"";
1027 # chat: Nexuiz server -> IRC channel, vote stop
1028 [ dp => q{:vote:vstop:(\d+)} => sub {
1030 my $oldnick = $store{"playernick_$id"};
1031 out irc => 0, "PRIVMSG $config{irc_channel} :* $oldnick\017 stopped the vote";
1035 # chat: Nexuiz server -> IRC channel, master login
1036 [ dp => q{:vote:vlogin:(\d+)} => sub {
1038 my $oldnick = $store{"playernick_$id"};
1039 out irc => 0, "PRIVMSG $config{irc_channel} :* $oldnick\017 logged in as master";
1043 # chat: Nexuiz server -> IRC channel, master do
1044 [ dp => q{:vote:vdo:(\d+):(.*)} => sub {
1045 my ($id, $command) = @_;
1046 my $oldnick = $store{"playernick_$id"};
1047 out irc => 0, "PRIVMSG $config{irc_channel} :* $oldnick\017 used his master status to do \"$command\017\"";
1051 # chat: Nexuiz server -> IRC channel, result
1052 [ dp => q{:vote:v(yes|no|timeout):(\d+):(\d+):(\d+):(\d+):(-?\d+)} => sub {
1053 my ($result, $yes, $no, $abstain, $not, $min) = @_;
1054 my $spam = "$yes:$no" . (($min >= 0) ? " ($min needed)" : "") . ", $abstain didn't care, $not didn't vote";
1055 out irc => 0, "PRIVMSG $config{irc_channel} :* the vote ended with $result: $spam";
1059 # chat: IRC channel -> Nexuiz server
1060 [ irc => q{:([^! ]*)![^ ]* (?i:PRIVMSG) (?i:(??{$config{irc_channel}})) :(?i:(??{$store{irc_nick}}))(?: |: ?)(.*)} => sub {
1061 my ($nick, $message) = @_;
1062 $nick = color_dpfix $nick;
1063 # allow the nickname to contain colors in DP format! Therefore, NO color_irc2dp on the nickname!
1064 $message = color_irc2dp $message;
1065 $message =~ s/(["\\])/\\$1/g;
1066 out dp => 0, "rcon2irc_say_as \"$nick on IRC\" \"$message\"";
1070 # irc: CTCP VERSION reply
1071 [ irc => q{:([^! ]*)![^ ]* (?i:PRIVMSG) (?i:(??{$store{irc_nick}})) :\001VERSION( .*)?\001} => sub {
1073 my $ver = $store{dp_version} or return 0;
1074 $ver .= ", rcon2irc $VERSION";
1075 out irc => 0, "NOTICE $nick :\001VERSION $ver\001";
1078 # on game start, notify the channel
1079 [ dp => q{:gamestart:(.*):[0-9.]*} => sub {
1081 $store{playing} = 1;
1083 $store{map_starttime} = time();
1084 my $slotsstr = nex_slotsstring();
1085 out irc => 0, "PRIVMSG $config{irc_channel} :\00304" . $map . "\017 has begun$slotsstr";
1086 delete $store{lms_blocked};
1090 # on game over, clear the current map
1091 [ dp => q{:gameover} => sub {
1092 $store{playing} = 0;
1096 # scores: Nexuiz server -> IRC channel (start)
1097 [ dp => q{:scores:(.*):(\d+)} => sub {
1098 my ($map, $time) = @_;
1099 $store{scores} = {};
1100 $store{scores}{map} = $map;
1101 $store{scores}{time} = $time;
1102 $store{scores}{players} = [];
1103 delete $store{lms_blocked};
1107 # scores: Nexuiz server -> IRC channel
1108 [ dp => q{:player:(-?\d+):(\d+):(\d+):(\d+):(\d+):(.*)} => sub {
1109 my ($frags, $deaths, $time, $team, $id, $name) = @_;
1110 return if not exists $store{scores};
1111 push @{$store{scores}{players}}, [$frags, $team, $name]
1112 unless $frags <= -666; # no spectators
1116 # scores: Nexuiz server -> IRC channel
1117 [ dp => q{:end} => sub {
1118 return if not exists $store{scores};
1119 my $s = $store{scores};
1120 delete $store{scores};
1121 my $teams_matter = nex_is_teamplay($s->{map});
1128 # put players into teams
1130 for(@{$s->{players}})
1132 my $thisteam = ($t{$_->[1]} ||= {score => 0, team => $_->[1], players => []});
1133 push @{$thisteam->{players}}, [$_->[0], $_->[1], $_->[2]];
1134 $thisteam->{score} += $_->[0];
1137 # sort by team score
1138 @t = sort { $b->{score} <=> $a->{score} } values %t;
1140 # sort by player score
1144 @{$_->{players}} = sort { $b->[0] <=> $a->[0] } @{$_->{players}};
1145 push @p, @{$_->{players}};
1150 @p = sort { $b->[0] <=> $a->[0] } @{$s->{players}};
1153 # no display for empty server
1157 # make message fit somehow
1158 for my $maxnamelen(reverse 3..64)
1160 my $scores_string = "PRIVMSG $config{irc_channel} :\00304" . $s->{map} . "\017 ended:";
1166 $scores_string .= $sep . sprintf "\003%02d\%d\017", $color_team2irc_table{$_->{team}}, $_->{score};
1173 my ($frags, $team, $name) = @$_;
1174 $name = color_dpfix substr($name, 0, $maxnamelen);
1177 $name = "\003" . $color_team2irc_table{$team} . " " . color_dp2none $name;
1181 $name = " " . color_dp2irc $name;
1183 $scores_string .= "$sep$name\017 $frags";
1186 if(length($scores_string) <= ($store{irc_maxlen} || 256))
1188 out irc => 0, $scores_string;
1192 out irc => 0, "PRIVMSG $config{irc_channel} :\001ACTION would have LIKED to put the scores here, but they wouldn't fit :(\001";
1196 # complain when system load gets too high
1197 [ dp => q{timing: (([0-9.]*)% CPU, ([0-9.]*)% lost, offset avg ([0-9.]*)ms, max ([0-9.]*)ms, sdev ([0-9.]*)ms)} => sub {
1198 my ($all, $cpu, $lost, $avg, $max, $sdev) = @_;
1199 return 0 # don't complain when just on the voting screen
1200 if !$store{playing};
1201 return 0 # don't complain if it was less than 0.5%
1203 return 0 # don't complain if nobody is looking
1204 if $store{slots_active} == 0;
1205 return 0 # don't complain in the first two minutes
1206 if time() - $store{map_starttime} < 120;
1207 return 0 # don't complain if it was already at least half as bad in this round
1208 if $store{map_starttime} == $store{timingerror_map_starttime} and $lost <= 2 * $store{timingerror_lost};
1209 $store{timingerror_map_starttime} = $store{map_starttime};
1210 $store{timingerror_lost} = $lost;
1211 out dp => 0, 'rcon2irc_say_as server "There are currently some severe system load problems. The admins have been notified."';
1212 out irc => 1, "PRIVMSG $config{irc_channel} :\001ACTION has big trouble on $store{map} after @{[int(time() - $store{map_starttime})]}s: $all\001";
1213 #out irc => 1, "PRIVMSG OpBaI :\001ACTION has big trouble on $store{map} after @{[int(time() - $store{map_starttime})]}s: $all\001";
1220 # Load plugins and add them to the handler list in the front.
1221 for my $p(split ' ', $config{plugins})
1223 my @h = eval { do $p; }
1224 or die "Invalid plugin $p: $@";
1227 ref $_ eq 'ARRAY' or die "Invalid plugin $p: did not return a list of arrays";
1228 @$_ == 3 or die "Invalid plugin $p: did not return a list of three-element arrays";
1229 !ref $_->[0] && !ref $_->[1] && ref $_->[2] eq 'CODE' or die "Invalid plugin $p: did not return a list of string-string-sub arrays";
1230 unshift @handlers, $_;
1236 # verify that the server is up by letting it echo back a string that causes
1237 # re-initialization of the required aliases
1238 out dp => 0, 'echo "Unknown command \"rcon2irc_eval\""'; # assume the server has been restarted
1242 # regularily, query the server status and if it still is connected to us using
1243 # the log_dest_udp feature. If not, we will detect the response to this rcon
1244 # command and re-initialize the server's connection to us (either by log_dest_udp
1245 # not containing our own IP:port, or by rcon2irc_eval not being a defined command).
1248 out dp => 0, 'status', 'log_dest_udp', 'rcon2irc_eval set dummy 1';
1249 schedule $timer => (exists $store{dp_hostname} ? $config{dp_status_delay} : 1);;
1254 # Continue with connecting to IRC as soon as we get our first status reply from
1255 # the DP server (which contains the server's hostname that we'll use as
1256 # realname for IRC).
1260 # log on to IRC when needed
1261 if(exists $store{dp_hostname} && !exists $store{irc_logged_in})
1263 $store{irc_nick_requested} = $config{irc_nick};
1264 out irc => 1, "NICK $config{irc_nick}", "USER $config{irc_user} localhost localhost :$store{dp_hostname}";
1265 $store{irc_logged_in} = 1;
1266 undef $store{irc_maxlen};
1267 undef $store{irc_pingtime};
1270 schedule $timer => 1;;
1275 # Regularily ping the IRC server to detect if the connection is down. If it is,
1276 # schedule an IRC error that will cause reconnection later.
1280 if($store{irc_logged_in})
1282 if(defined $store{irc_pingtime})
1284 # IRC connection apparently broke
1285 # so... KILL IT WITH FIRE
1286 $channels{system}->send("error irc", 0);
1290 # everything is fine, send a new ping
1291 $store{irc_pingtime} = time();
1292 out irc => 1, "PING $store{irc_pingtime}";
1296 schedule $timer => $config{irc_ping_delay};;
1304 # wait for something to happen on our sockets, or wait 2 seconds without anything happening there
1306 my @errors = $s->has_exception(0);
1308 # on every channel, look for incoming messages
1310 for my $chanstr(keys %channels)
1312 my $chan = $channels{$chanstr};
1313 my @chanfds = $chan->fds();
1315 for my $chanfd(@chanfds)
1317 if(grep { $_ == $chanfd } @errors)
1319 # STOP! This channel errored!
1320 $channels{system}->send("error $chanstr", 0);
1327 for my $line($chan->recv())
1329 # found one! Check if it matches the regular expression of one of
1332 for my $h(@handlers)
1334 my ($chanstr_wanted, $re, $sub) = @$h;
1336 if $chanstr_wanted ne $chanstr;
1338 my @matches = ($line =~ /^$re$/s);
1342 # and if it is a match, handle it.
1344 my $result = $sub->(@matches);
1348 # print the message, together with info on whether it has been handled or not
1351 print " $chanstr >> $line\n";
1355 print "unhandled: $chanstr >> $line\n";
1360 if($@ eq "read error\n")
1362 $channels{system}->send("error $chanstr", 0);
1373 # handle scheduled tasks...
1376 # by emptying the list of tasks...
1380 my ($time, $sub) = @$_;
1383 # calling them if they are schedled for the "past"...
1388 # or re-adding them to the task list if they still are scheduled for the "future"
1389 push @tasks, [$time, $sub];