The Perl Toolchain Summit needs more sponsors. If your company depends on Perl, please support this very important event.
package POEx::IRC::Backend;
$POEx::IRC::Backend::VERSION = '0.030002';
use strictures 2;

use Carp;
use Scalar::Util 'blessed';

use IRC::Message::Object ();

use Net::IP::Minimal 'ip_is_ipv6';

use POE qw/
  Wheel::ReadWrite
  Wheel::SocketFactory

  Filter::Stackable
  Filter::IRCv3
  Filter::Line
/;

use Socket qw/
  AF_INET AF_INET6
  pack_sockaddr_in

  getnameinfo
  NI_NUMERICHOST
  NI_NUMERICSERV
  NIx_NOSERV
/;

use Types::Standard -all;
use Types::TypeTiny -all;

use Try::Tiny;

use POEx::IRC::Backend::Connect;
use POEx::IRC::Backend::Connector;
use POEx::IRC::Backend::Listener;


sub RUNNING_IN_HELL () { $^O =~ /(cygwin|MSWin32)/ }

sub get_unpacked_addr {
  my ($sock_packed, %params) = @_;
  my ($err, $addr, $port) = getnameinfo $sock_packed,
     NI_NUMERICHOST | NI_NUMERICSERV,
      ( $params{noserv} ? NIx_NOSERV : () );
  croak $err if $err;
  $params{noserv} ? $addr : ($addr, $port)
}


use Moo 2;
with 'POEx::IRC::Backend::Role::CheckAvail';


has session_id => (
  init_arg  => undef,
  lazy      => 1,
  is        => 'ro',
  writer    => '_set_session_id',
);

has controller => (
  ## Session ID for controller session
  ## Typically set by 'register' event, though it doesn't have to be:
  lazy      => 1,
  is        => 'ro',
  writer    => '_set_controller',
  predicate => 'has_controller',
);

has filter_irc => (
  lazy    => 1,
  isa     => InstanceOf['POE::Filter'],
  is      => 'ro',
  default => sub { POE::Filter::IRCv3->new },
);

has filter_line => (
  lazy    => 1,
  isa     => InstanceOf['POE::Filter'],
  is      => 'ro',
  default => sub {
    POE::Filter::Line->new(
      InputRegexp   => '\015?\012',
      OutputLiteral => "\015\012",
    )
  },
);

has filter => (
  lazy    => 1,
  isa     => InstanceOf['POE::Filter'],
  is      => 'ro',
  default => sub {
    my ($self) = @_;
    POE::Filter::Stackable->new(
      Filters => [ $self->filter_line, $self->filter_irc ],
    );
  },
);

has listeners => (
  ## POEx::IRC::Backend::Listener objs
  ## These are listeners for a particular port.
  init_arg => undef,
  is      => 'ro',
  isa     => HashRef,
  writer  => '_set_listeners',
  default => sub { +{} },
);

has connectors => (
  ## POEx::IRC::Backend::Connector objs
  ## These are outgoing (peer) connectors.
  init_arg => undef,
  is      => 'ro',
  isa     => HashRef,
  writer  => '_set_connectors',
  default => sub { +{} },
);

has wheels => (
  ## POEx::IRC::Backend::Connect objs
  ## These are our connected wheels.
  init_arg  => undef,
  is        => 'ro',
  isa       => HashRef,
  writer    => '_set_wheels',
  default   => sub { +{} },
);

has ssl_context => (
  lazy      => 1,
  is        => 'ro',
  writer    => '_set_ssl_context',
  default   => sub { undef },
);


sub spawn {
  my ($class, %args) = @_;
  my $ssl_opts = delete $args{ssl_opts};
  my $self = blessed $class ? $class : $class->new(%args);

  POE::Session->create(
    object_states => [
      $self => {
        _start => '_start',
        _stop  => '_stop',

        register          => '_register_controller',
        shutdown          => '_shutdown',
        create_connector  => '_create_connector',
        create_listener   => '_create_listener',
        remove_listener   => '_remove_listener',
        send              => '_send',

        _accept_conn_v4   => '_accept_conn',
        _accept_conn_v6   => '_accept_conn',
        _accept_fail      => '_accept_fail',
        _idle_alarm       => '_idle_alarm',

        _connector_up_v4  => '_connector_up',
        _connector_up_v6  => '_connector_up',
        _connector_failed => '_connector_failed',

        _ircsock_input    => '_ircsock_input',
        _ircsock_error    => '_ircsock_error',
        _ircsock_flushed  => '_ircsock_flushed',
      },
    ],
  ) or confess "Failed to spawn POE::Session";

  if (defined $ssl_opts) {
    confess "expected ssl_opts to be an ARRAY but got $ssl_opts"
      unless ref $ssl_opts eq 'ARRAY';
    my $ssl_err;
    try {
      die "Failed to load POE::Component::SSLify" unless $self->has_ssl_support;
      $self->_set_ssl_context(
        POE::Component::SSLify::SSLify_ContextCreate( @$ssl_opts )
      );
      1
    } catch {
      $ssl_err = $_;
      undef
    } or confess "SSLify failure: $ssl_err";
  }

  $self
}

sub _start {
  my ($kernel, $self) = @_[KERNEL, OBJECT];
  $self->_set_session_id( $_[SESSION]->ID );
  $kernel->refcount_increment( $self->session_id, "IRCD Running" );
}

sub _stop {}

sub shutdown {
  my $self = shift;
  $poe_kernel->post( $self->session_id => shutdown => @_ )
}

sub _shutdown {
  my ($kernel, $self) = @_[KERNEL, OBJECT];

  $kernel->refcount_decrement( $self->session_id => "IRCD Running" );
  $kernel->refcount_decrement( $self->controller => "IRCD Running" );

  ## _disconnected should also clear our alarms.
  $self->_disconnected($_, "Server shutdown") for keys %{ $self->wheels };

  for my $attr (map {; '_set_'.$_ } qw/ listeners connectors wheels /) {
    $self->$attr(+{})
  }
}

sub _register_controller {
  my ($kernel, $self) = @_[KERNEL, OBJECT];

  $kernel->refcount_decrement( $self->controller => "IRCD Running" )
    if $self->has_controller;
  $self->_set_controller( $_[SENDER]->ID );
  $kernel->refcount_increment( $self->controller => "IRCD Running" );

  $kernel->post( $self->controller => ircsock_registered => $self );
}

sub _idle_alarm {
  my ($kernel, $self, $w_id) = @_[KERNEL, OBJECT, ARG0];
  my $this_conn = $self->wheels->{$w_id} || return;

  $kernel->post( $self->controller => ircsock_connection_idle => $this_conn );

  $this_conn->alarm_id(
    $kernel->delay_set( _idle_alarm => $this_conn->idle, $w_id )
  );
}

sub create_listener {
  my $self = shift;
  $poe_kernel->post( $self->session_id => create_listener => @_ );
  $self
}

sub _create_listener {
  my ($kernel, $self, %args) = @_[KERNEL, OBJECT, ARG0 .. $#_];
  $args{lc $_} = delete $args{$_} for keys %args;

  my $bindaddr  = delete $args{bindaddr} || '0.0.0.0';
  my $bindport  = delete $args{port}     || 0;

  my $protocol = ( delete $args{ipv6} || ip_is_ipv6($bindaddr) ) ? 6 : 4;

  my $wheel = POE::Wheel::SocketFactory->new(
    SocketDomain => ($protocol == 6 ? AF_INET6 : AF_INET),
    BindAddress  => $bindaddr,
    BindPort     => $bindport,
    SuccessEvent => 
      ( $protocol == 6 ? '_accept_conn_v6' : '_accept_conn_v4' ),
    FailureEvent => '_accept_fail',
    Reuse        => 1,
  );

  my $id = $wheel->ID;

  my $listener = POEx::IRC::Backend::Listener->new(
    protocol => $protocol,
    wheel => $wheel,
    addr  => $bindaddr,
    port  => $bindport,
    idle  => ( delete($args{idle}) || 180 ),
    ssl   => ( delete($args{ssl})  || 0 ),
    ( keys %args ? (args => \%args) : () ),
  );

  $self->listeners->{$id} = $listener;

  ## Real bound port/addr
  my (undef, $port) = get_unpacked_addr( $wheel->getsockname );
  $listener->set_port($port) if $port;

  $kernel->post( $self->controller => ircsock_listener_created => $listener )
}

sub remove_listener {
  my $self = shift;
  $poe_kernel->post( $self->session_id => remove_listener => @_ );
  $self
}

sub _remove_listener {
  my ($kernel, $self) = @_[KERNEL, OBJECT];
  my %args = @_[ARG0 .. $#_];
  $args{lc $_} = delete $args{$_} for keys %args;

  if (defined $args{listener} && $self->listeners->{ $args{listener} }) {
    my $listener = delete $self->listeners->{ $args{listener} };
    $listener->clear_wheel;
    $kernel->post( $self->controller =>
      ircsock_listener_removed => $listener
    );
    return
  }

  my @removed;

  LISTENER: for my $id (keys %{ $self->listeners }) {
    my $listener = $self->listeners->{$id};
    if (defined $args{port} && defined $args{addr}) {
      if ($args{addr} eq $listener->addr && $args{port} eq $listener->port) {
        delete $self->listeners->{$id};
        push @removed, $listener;
        next LISTENER
      }
    } elsif (defined $args{addr} && $args{addr} eq $listener->addr) {
      delete $self->listeners->{$id};
      push @removed, $listener;
    } elsif (defined $args{port} && $args{port} eq $listener->port) {
      delete $self->listeners->{$id};
      push @removed, $listener;
    }
  }

  for my $listener (@removed) {
    $listener->clear_wheel;
    $kernel->post( $self->controller => 
      ircsock_listener_removed => $listener 
    );
  }
}

sub _accept_fail {
  my ($kernel, $self) = @_[KERNEL, OBJECT];
  my ($op, $errnum, $errstr, $listener_id) = @_[ARG0 .. ARG3];

  my $listener = delete $self->listeners->{$listener_id};
  if ($listener) {
    $listener->clear_wheel;
    $kernel->post( $self->controller => 
      ircsock_listener_failure => $listener, $op, $errnum, $errstr
    );
  }
}

sub _accept_conn {
  ## Accepted connection to a listener.
  my ($self, $sock, $p_addr, $p_port, $listener_id) = @_[OBJECT, ARG0 .. ARG3];

  my ($protocol, $un_p_addr) = $_[STATE] eq '_accept_conn_v6' ?
    ( 6, $p_addr )
    : ( 4,
        get_unpacked_addr( pack_sockaddr_in($p_port, $p_addr), noserv => 1 )
      ) 
  ;

  my $listener = $self->listeners->{$listener_id};
  my $using_ssl = $listener->ssl;
  if ($using_ssl) {
    try {
      die "Failed to load POE::Component::SSLify" unless $self->has_ssl_support;
      $sock = POE::Component::SSLify::Server_SSLify($sock, $self->ssl_context);
    } catch {
      warn "Could not SSLify (server) socket: $_";
      undef
    } or return;
  }

  my $wheel = POE::Wheel::ReadWrite->new(
    Handle => $sock,
    Filter => $self->filter,
    InputEvent   => '_ircsock_input',
    ErrorEvent   => '_ircsock_error',
    FlushedEvent => '_ircsock_flushed',
  );

  unless ($wheel) {
    warn "Wheel creation failure in _accept_conn";
    return
  }

  my ($sockaddr, $sockport) = get_unpacked_addr( 
    getsockname(
      $using_ssl ? POE::Component::SSLify::SSLify_GetSocket($sock) : $sock
    )
  );

  my $w_id = $wheel->ID;
  my $this_conn = $self->wheels->{$w_id} = 
    POEx::IRC::Backend::Connect->new(
      ($listener->has_args ? (args => $listener->args) : () ),
      protocol  => $protocol,
      wheel     => $wheel,
      peeraddr  => $un_p_addr,
      peerport  => $p_port,
      sockaddr  => $sockaddr,
      sockport  => $sockport,
      seen      => time,
      idle      => $listener->idle,
      ssl       => $using_ssl,
    );

  $this_conn->alarm_id(
    $poe_kernel->delay_set( _idle_alarm => $this_conn->idle, $w_id )
  );

  $poe_kernel->post( $self->controller => 
    ircsock_listener_open => $this_conn, $listener
  );
}

sub create_connector {
  my $self = shift;
  $poe_kernel->post( $self->session_id => create_connector => @_ );
  $self
}

sub _create_connector {
  ## Connector; try to spawn socket <-> remote peer
  ##  remoteaddr =>
  ##  remoteport =>
  ## [optional]
  ##  bindaddr =>
  ##  ipv6 =>
  ##  ssl  =>
  ## ... other args get added to ->args()
  my (undef, $self) = @_[KERNEL, OBJECT];
  my %args = @_[ARG0 .. $#_];

  $args{lc $_} = delete $args{$_} for keys %args;

  my $remote_addr = delete $args{remoteaddr};
  my $remote_port = delete $args{remoteport};

  die "create_connector expects a remoteaddr and remoteport\n"
    unless defined $remote_addr and defined $remote_port;

  my $protocol =
      delete $args{ipv6}                                 ? 6
    : ip_is_ipv6($remote_addr)                           ? 6
    : ( $args{bindaddr} && ip_is_ipv6($args{bindaddr}) ) ? 6
                                                         : 4;

  my $wheel = POE::Wheel::SocketFactory->new(
    SocketDomain   => ($protocol == 6 ? AF_INET6 : AF_INET),
    SocketProtocol => 'tcp',

    RemoteAddress  => $remote_addr,
    RemotePort     => $remote_port,

    FailureEvent   => '_connector_failed',
    SuccessEvent   => 
      ( $protocol == 6 ? '_connector_up_v6' : '_connector_up_v4' ),

    (
      defined $args{bindaddr} ?
        ( BindAddress => delete $args{bindaddr} ) : () 
    ),
  );

  my $id = $wheel->ID;

  $self->connectors->{$id} = POEx::IRC::Backend::Connector->new(
    wheel     => $wheel,
    addr      => $remote_addr,
    port      => $remote_port,
    protocol  => $protocol,

    (defined $args{ssl}      ?
      (ssl      => delete $args{ssl}) : () ),

    (defined $args{bindaddr} ?
      (bindaddr => delete $args{bindaddr}) : () ),

    ## Attach any extra args to Connector->args()
    (keys %args ?
      (args => \%args) : () ),
  );
}


sub _connector_up {
  my ($kernel, $self, $sock, $p_addr, $p_port, $c_id)
    = @_[KERNEL, OBJECT, ARG0 .. ARG3];

  my ($protocol, $un_p_addr);
  if ($_[STATE] eq '_connector_up_v6') {
    $protocol  = 6;
    $un_p_addr = $p_addr;
  } else {
    $protocol  = 4;
    $un_p_addr = get_unpacked_addr(
      pack_sockaddr_in($p_port, $p_addr), noserv => 1
    );
  }

  ## No need to try to connect out any more; remove from connectors pool:
  my $ct = delete $self->connectors->{$c_id};

  my $using_ssl;
  if ( $ct->ssl ) {
    try {
      die "Failed to load POE::Component::SSLify" unless $self->has_ssl_support;
      $sock = POE::Component::SSLify::Client_SSLify(
        $sock, undef, undef, $self->ssl_context
      );
      $using_ssl = 1
    } catch {
      warn "Could not SSLify (client) socket: $_\n";
      undef
    } or return;
  }

  my $wheel = POE::Wheel::ReadWrite->new(
    Handle       => $sock,
    InputEvent   => '_ircsock_input',
    ErrorEvent   => '_ircsock_error',
    FlushedEvent => '_ircsock_flushed',
    Filter       => POE::Filter::Stackable->new(
      Filters => [ $self->filter ],
    )
  );

  unless ($wheel) {
    warn "Wheel creation failure in _connector_up";
    return
  }

  my ($sockaddr, $sockport) = get_unpacked_addr(
    getsockname(
      $using_ssl ? POE::Component::SSLify::SSLify_GetSocket($sock) : $sock
    )
  );

  my $this_conn = POEx::IRC::Backend::Connect->new(
    ($ct->has_args ? (args => $ct->args) : () ),
    protocol => $protocol,
    wheel    => $wheel,
    peeraddr => $un_p_addr,
    peerport => $p_port,
    sockaddr => $sockaddr,
    sockport => $sockport,
    seen => time,
    ssl  => $using_ssl,
  );

  $self->wheels->{ $wheel->ID } = $this_conn;

  $kernel->post( $self->controller => 
    ircsock_connector_open => $this_conn
  );
}

sub _connector_failed {
  my ($kernel, $self) = @_[KERNEL, OBJECT];
  my ($op, $errno, $errstr, $c_id) = @_[ARG0 .. ARG3];

  my $ct = delete $self->connectors->{$c_id};
  $ct->clear_wheel;

  $kernel->post( $self->controller =>
    ircsock_connector_failure => $ct, $op, $errno, $errstr
  );
}

## _ircsock_* handlers talk to endpoints via listeners/connectors

sub _ircsock_input {
  # ($input, $w_id)  = @_[ARG0, ARG1];
  my $this_conn = $_[OBJECT]->wheels->{ $_[ARG1] };
  $this_conn->seen( time );
  $poe_kernel->delay_adjust( $this_conn->alarm_id, $this_conn->idle )
    if $this_conn->has_alarm_id;

  $poe_kernel->post( $_[OBJECT]->controller => 
    ircsock_input => $this_conn, IRC::Message::Object->new(%{ $_[ARG0] })
  );
}

sub _ircsock_error {
  ## Lost someone.
  my (undef, $self) = @_[KERNEL, OBJECT];
  my ($errstr, $w_id) = @_[ARG2, ARG3];

  my $this_conn = $self->wheels->{$w_id} || return;

  $self->_disconnected(
    $w_id,
    $errstr || $this_conn->is_disconnecting
  );
}

sub _ircsock_flushed {
  ## Socket's been flushed; we may have something to do.
  my ($self, $w_id) = @_[OBJECT, ARG0];

  my $this_conn = $self->wheels->{$w_id} || return;

  if ($this_conn->is_disconnecting) {
    return $self->_disconnected( $w_id, $this_conn->is_disconnecting )
  }

  if ($this_conn->is_pending_compress) {
    return $self->set_compressed_link_now($w_id)
  }
}

sub _send {
  ## POE bridge to send()
  $_[OBJECT]->send(@_[ARG0 .. $#_ ]);
}

## Methods.

sub send {
  ## ->send(HASH, ID [, ID .. ])
  my ($self, $out, @ids) = @_;

  if (blessed $out && $out->isa('IRC::Message::Object')) {
    # breaks encapsulation for performance reasons:
    $out = +{
      command => $out->command,
      (
        map {; exists $out->{$_} ? ($_ => $out->{$_}) : () }
          qw/ colonify prefix params tags /
      ),
    };
  }

  confess 
    "send() takes a HASH or IRC::Message::Object and a list of connection IDs"
    unless ref $out eq 'HASH' and @ids;

  TARGET: for my $target (@ids) {
    # FIXME tests/docs wrt passing in Connect objs
    $target = $target->wheel_id if blessed $target;
    ($self->wheels->{$target} || next TARGET)->wheel->put($out)
  }

  $self
}

sub disconnect {
  # Mark a wheel for disconnection at next flush.
  my ($self, $w_id, $str) = @_;
  $w_id = $w_id->wheel_id if blessed $w_id;
  confess "disconnect() needs an (extant) wheel ID or Connect object"
    unless defined $w_id;

  # Application code should probably check $conn->has_wheel before trying to
  # call a ->disconnect, but if not, it's hard to determine if we were passed
  # junk or just racing against an already-gone wheel:
  unless (defined $self->wheels->{$w_id}) {
    carp "Attempting to disconnect() unknown wheel '$w_id'\n",
      " This warning may be spurious. Your wheel may have died of natural causes.\n",
      " Try checking '\$conn->has_wheel' before calling disconnect()." ;
    return
  }

  $self->wheels->{$w_id}->is_disconnecting($str // "Client disconnect");
  $self
}

sub disconnect_now {
  my ($self, $w_id, $str) = @_;
  $w_id = $w_id->wheel_id if blessed $w_id;
  confess "disconnect_now needs an (extant) wheel ID or Connect object"
    unless defined $w_id;

  unless (defined $self->wheels->{$w_id}) {
    carp "Attempting to disconnect() unknown wheel '$w_id'\n",
      " This warning may be spurious. Your wheel may have died of natural causes.\n",
      " Try checking '\$conn->has_wheel' before calling disconnect()." ;
    return
  }

  $self->_disconnected($w_id, $str // "Client disconnect");
  $self
}

sub _disconnected {
  ## Wheel needs cleanup.
  my ($self, $w_id, $str) = @_;
  return unless $w_id and $self->wheels->{$w_id};

  my $this_conn = delete $self->wheels->{$w_id};

  ## Idle timer cleanup
  $poe_kernel->alarm_remove( $this_conn->alarm_id ) 
    if $this_conn->has_alarm_id;

  ## Already a dead Connect (wheel was cleared), nothing to do
  return unless $this_conn->has_wheel;

  if (RUNNING_IN_HELL) {
    $this_conn->wheel->shutdown_input;
    $this_conn->wheel->shutdown_output;
  }

  $this_conn->clear_wheel;

  $poe_kernel->post( $self->controller => 
    ircsock_disconnect => $this_conn, $str
  );

  1
}

sub set_compressed_link {
  my ($self, $w_id) = @_;
  confess "set_compressed_link() needs a wheel ID"
    unless defined $w_id;

  confess "Failed to load POE::Filter::Zlib::Stream"
    unless $self->has_zlib_support;

  unless ($self->wheels->{$w_id}) {
    carp "set_compressed_link for nonexistant wheel '$w_id'";
    return
  }

  $self->wheels->{$w_id}->is_pending_compress(1);

  $self
}

sub set_compressed_link_now {
  my ($self, $w_id) = @_;
  confess "set_compressed_link() needs a wheel ID"
    unless defined $w_id;
 
  my $this_conn = $self->wheels->{$w_id};
  unless (defined $this_conn) {
    carp "set_compressed_link_now for nonexistant wheel '$w_id'";
    return
  }

  confess "Failed to load POE::Filter::Zlib::Stream"
    unless $self->has_zlib_support;

  $this_conn->wheel->get_input_filter->unshift(
    POE::Filter::Zlib::Stream->new
  );

  $this_conn->is_pending_compress(0);
  $this_conn->set_compressed(1);

  $poe_kernel->post( $self->controller =>
    ircsock_compressed => $this_conn
  );

  $self
}

sub unset_compressed_link {
  my ($self, $w_id) = @_;
  confess "unset_compressed_link() needs a wheel ID"
    unless defined $w_id;

  my $this_conn = $self->wheels->{$w_id};
  unless (defined $this_conn && $this_conn->compressed) {
    carp 
      "unset_compressed_link on uncompressed or nonexistant wheel '$w_id'";
    return
  }

  $this_conn->wheel->get_input_filter->shift;
  $this_conn->set_compressed(0);

  $self
}

no warnings 'void';
print
 qq[<CaptObviousman> pretend for a moment that I'm stuck with mysql\n],
 qq[<rnowak> ok, fetching my laughing hat and monocle\n],
unless caller; 1;


=pod

=for Pod::Coverage has_\w+ RUNNING_IN_HELL get_unpacked_addr

=head1 NAME

POEx::IRC::Backend - IRC client or server backend

=head1 SYNOPSIS

  use POE;
  use POEx::IRC::Backend;

  POE::Session->create(
    package_states => [
      main => [ qw/
        _start
        ircsock_registered
        ircsock_input
      / ],
    ],
  );

  sub _start {
    # Spawn a Backend and register as the controlling session:
    my $backend = POEx::IRC::Backend->spawn;
    $_[HEAP]->{backend} = $backend;
    $_[KERNEL]->post( $backend->session_id, 'register' );
  }

  sub ircsock_registered {
    my $backend = $_[HEAP]->{backend};

    # Listen for incoming IRC traffic:
    $backend->create_listener(
      bindaddr => $addr,
      port     => $port,
    );

    # Connect to a remote endpoint:
    $backend->create_connector(
      remoteaddr => $remote,
      remoteport => $remoteport,
      # Optional:
      bindaddr => $bindaddr,
      ipv6     => 1,
      ssl      => 1,
    );
  }

  # Handle and dispatch incoming IRC events:
  sub ircsock_input {
    # POEx::IRC::Backend::Connect obj:
    my $this_conn = $_[ARG0];

    # IRC::Message::Object obj:
    my $input_obj = $_[ARG1];

    my $cmd = $input_obj->command;

    # ... dispatch, etc ...
  }

=head1 DESCRIPTION

A L<POE> IRC socket handler that can be used (by client or server
implementations) to speak the IRC protocol to endpoints via
L<IRC::Message::Object> objects.

Inspired by L<POE::Component::Server::IRC::Backend> & L<POE::Component::IRC>.

This is a very low-level interface to IRC sockets; the goal is to provide all
the necessary scaffolding to develop stateless or stateful IRC clients and
daemons. See L<POEx::IRC::Client::Lite> for an experimental IRC client library
using this backend (and the L</"SEE ALSO"> section of this documentation for
related tools).

=head2 Attributes

=head3 controller

Retrieve the L<POE::Session> ID for the backend's registered controller.

Predicate: B<has_controller>

=head3 connectors

A HASH of active Connector objects, keyed on their wheel ID.

=head3 filter

A L<POE::Filter::Stackable> instance consisting of the current L</filter_irc>
stacked with L</filter_line> (at the time the attribute is built).

=head3 filter_irc

A L<POE::Filter::IRCv3> instance with B<colonify> disabled, by default (this
behavior changed in v0.27.2).

A server-side Backend may want a colonifying filter:

  my $backend = POEx::IRC::Backend->new(
    filter_irc => POE::Filter::IRCv3->new(colonify => 1),
    ...
  );

=head3 filter_line

A L<POE::Filter::Line> instance.

=head3 listeners

HASH of active Listener objects, keyed on their wheel ID.

=head3 session_id

Returns the backend's session ID.

=head3 ssl_context

Returns the L<Net::SSLeay> Context object, if we have one (or C<undef> if
not); the context is set up by L</spawn> if C<ssl_opts> are specified.

=head3 wheels

HASH of actively connected wheels, keyed on their wheel ID.


=head2 Methods

=head3 spawn

  my $backend = POEx::IRC::Backend->spawn(
    ## Optional, needed for SSL-ified server-side sockets
    ssl_opts => [
      'server.key',
      'server.cert',
    ],
  );

Creates the backend's L<POE::Session>.

The C<ssl_opts> ARRAY is passed directly to
L<POE::Component::SSLify/SSLify_ContextCreate>, if present. As of C<v0.28.x>,
each Backend gets its own L<Net::SSLeay> context object (rather than sharing
the global context). See L<POE::Component::SSLify> & L<Net::SSLeay>.

=head3 create_connector

  $backend->create_connector(
    remoteaddr => $addr,
    remoteport => $addr,
    ## Optional:
    bindaddr => $local_addr,
    ipv6 => 1,
    ssl  => 1,
    ## Unrecognized opts are stored in the Connector's 'args' hash:
    tag   => 'foo',
  );

Attempts to create a L<POEx::IRC::Backend::Connector> that 
holds a L<POE::Wheel::SocketFactory> connector wheel; connectors will 
attempt to establish an outgoing connection immediately.

Unrecognized options are stored in the L<POEx::IRC::Backend::Connector>'s
C<args> HASH-type attribute; this is passed to successfully created
L<POEx::IRC::Backend::Connect> instances (as of C<v0.26.x>). Note that the
reference is shared, not copied.

=head3 create_listener

  $backend->create_listener(
    bindaddr => $addr,
    port     => $port,
    ## Optional:
    ipv6     => 1,
    ssl      => 1,
    idle     => $seconds,
  );

Attempts to create a L<POEx::IRC::Backend::Listener> 
that holds a L<POE::Wheel::SocketFactory> listener wheel.

Unrecognized arguments will be added to the Listener object's C<args>
attribute, which is then passed on to L<POEx::IRC::Backend::Connect> objects
created by incoming connections to that listener, similar to the behavior
described in L</create_connector> (as of C<v0.28.x>).

=head3 remove_listener

    $backend->remove_listener(
      listener => $listener_id,
    );

    ## or via addr, port, or combination thereof:
    $backend->remove_listener(
      addr => '127.0.0.1',
      port => 6667,
    );

Removes a listener and clears its B<wheel> attribute; the socket shuts down
when the L<POE::Wheel::SocketFactory> wheel goes out of scope.

=head3 disconnect

  $backend->disconnect($wheel_id, $disconnect_string);

Given a L<POEx::IRC::Backend::Connect> or its C<wheel_id>, mark the specified
wheel for disconnection.

This method will warn if the given C<wheel_id> cannot be found, which may be
due to the connection disappearing prior to calling C<disconnect>.

You can avoid spurious warnings by checking if the
L<POEx::IRC::Backend::Connect> still has an active wheel attached:

  if ($this_conn->has_wheel) {
    $backend->disconnect( $this_conn )
  }

Note that disconnection typically happens after a buffer flush; if your
software does not perform entirely like a traditional platform (server
implementations will typically send C<< ERROR: Closing Link >> or similar to
clients marked for disconnection, which will trigger a buffer flush) you may
currently experience "late" disconnects. See L</disconnect_now>.

=head3 disconnect_now

Like L</disconnect>, but attempt to destroy the wheel immediately (without
waiting for a buffer flush).

=head3 send

  $backend->send(
    {
      prefix  => $prefix,
      params  => [ @params ],
      command => $cmd,
    },
    @connect_ids
  );

  use IRC::Message::Object 'ircmsg';
  my $msg = ircmsg(
    command => 'PRIVMSG',
    params  => [ $chan, $string ],
  );
  $backend->send( $msg, $connect_obj );

Feeds L<POE::Filter::IRCv3> and sends the resultant raw IRC 
line to the specified connection wheel ID(s) or L<POEx::IRC::Backend::Connect>
object(s).

Accepts either an L<IRC::Message::Object> or a HASH compatible with
L<POE::Filter::IRCv3> -- look there for details.

Note that unroutable (target connection IDs with no matching live
wheel) messages are silently dropped. You can check L</wheels> yourself before
sending if this behavior is unwanted:

  for my $target (@connect_ids) {
    unless (exists $backend->wheels->{$target}) {
      warn "Cannot send to nonexistant target '$target'";
      next
    }
    $backend->send(
        { prefix => $prefix, params => [ @params ], command => $cmd },
        $target
    );
  }

=head3 has_ssl_support

Returns true if L<POE::Component::SSLify> was successfully loaded.

=head3 has_zlib_support

Returns true if L<POE::Filter::Zlib::Stream> was successfully loaded.

=head3 set_compressed_link

  $backend->set_compressed_link( $conn_id );

Mark a specified connection wheel ID as pending compression; 
L<POE::Filter::Zlib::Stream> will be added to the filter stack when the 
next flush event arrives.

This method will die unless L</has_zlib_support> is true.

=head3 set_compressed_link_now

  $backend->set_compressed_link_now( $conn_id );

Add a L<POE::Filter::Zlib::Stream> to the connection's filter stack 
immediately, rather than upon next flush event.

This method will die unless L</has_zlib_support> is true.

=head3 unset_compressed_link

  $backend->unset_compressed_link( $conn_id );

Remove L<POE::Filter::Zlib::Stream> from the connection's filter stack.


=head2 Received events

=head3 register

  $poe_kernel->post( $backend->session_id,
    'register'
  );

Register the sender session as the backend's controller session. The last 
session to send 'register' is the session that receives notification 
events from the backend component.

=head3 create_connector

Event interface to I<create_connector> -- see L</Methods>

=head3 create_listener

Event interface to I<create_listener> -- see L</Methods>

=head3 remove_listener

Event interface to I<remove_listener> -- see L</Methods>

=head3 send

Event interface to I</send> -- see L</Methods>

=head3 shutdown

Disconnect all wheels and clean up.


=head2 Dispatched events

These events are dispatched to the controller session; see L</register>.

=head3 ircsock_compressed

Dispatched when a connection wheel has had a compression filter added.

C<$_[ARG0]> is the connection's L<POEx::IRC::Backend::Connect>.

=head3 ircsock_connection_idle

Dispatched when a connection wheel has had no input for longer than 
specified idle time (see L</create_listener> regarding idle times).

Currently these events are only issued for incoming Connects accepted on a
Listener, not outgoing Connects created by a Connector; if you need to do
ping/pong-style heartbeating on an outgoing Connector-spawned socket, you will
need to run your own timer.

C<$_[ARG0]> is the connection's L<POEx::IRC::Backend::Connect>.

See also: L<POEx::IRC::Backend::Connect/ping_pending>

=head3 ircsock_connector_failure

Dispatched when a Connector has failed due to some sort of socket error.

C<$_[ARG0]> is the connection's 
L<POEx::IRC::Backend::Connector> with wheel() cleared.

C<@_[ARG1 .. ARG3]> contain the socket error details reported by 
L<POE::Wheel::SocketFactory>; operation, errno, and errstr, respectively.

=head3 ircsock_connector_open

Dispatched when a Connector has established a connection to a peer.

C<$_[ARG0]> is the L<POEx::IRC::Backend::Connect> for the 
connection.

=head3 ircsock_disconnect

Dispatched when a connection wheel has been cleared.

C<$_[ARG0]> is the connection's L<POEx::IRC::Backend::Connect> 
with wheel() cleared.

=head3 ircsock_input

Dispatched when there is some IRC input from a connection wheel.

C<$_[ARG0]> is the connection's 
L<POEx::IRC::Backend::Connect>.

C<$_[ARG1]> is an L<IRC::Message::Object>.

=head3 ircsock_listener_created

Dispatched when a L<POEx::IRC::Backend::Listener> has been 
created.

C<$_[ARG0]> is the L<POEx::IRC::Backend::Listener> instance; 
the instance's port() is altered based on getsockname() details after 
socket creation and before dispatching this event.

=head3 ircsock_listener_failure

Dispatched when a Listener has failed due to some sort of socket error.

C<$_[ARG0]> is the L<POEx::IRC::Backend::Listener> object.

C<@_[ARG1 .. ARG3]> contain the socket error details reported by 
L<POE::Wheel::SocketFactory>; operation, errno, and errstr, respectively.

=head3 ircsock_listener_open

Dispatched when a listener accepts a connection.

C<$_[ARG0]> is the connection's L<POEx::IRC::Backend::Connect>

C<$_[ARG1]> is the connection's L<POEx::IRC::Backend::Listener>

=head3 ircsock_listener_removed

Dispatched when a Listener has been removed.

C<$_[ARG0]> is the L<POEx::IRC::Backend::Listener> object.

=head3 ircsock_registered

Dispatched when a L</register> event has been successfully received, as a 
means of acknowledging the controlling session.

C<$_[ARG0]> is the Backend's C<$self> object.

=head1 BUGS

Probably lots. Please report them via RT, e-mail, IRC
(C<irc.cobaltirc.org#perl>), or GitHub
(L<http://github.com/avenj/poex-irc-backend>).

=head1 SEE ALSO

L<POEx::IRC::Backend::Connect>

L<POEx::IRC::Backend::Connector>

L<POEx::IRC::Backend::Listener>

L<POEx::IRC::Backend::Role::Socket>

L<POEx::IRC::Backend::Role::HasEndpoint>

L<POEx::IRC::Backend::Role::HasWheel>

L<POEx::IRC::Client::Lite> for an experimental IRC client library using this
backend.

L<https://github.com/miniCruzer/irssi-bouncer> for an irssi-based
bouncer/proxy system using this backend.

L<POE::Filter::IRCv3> and L<IRC::Message::Object> for documentation regarding
IRC message parsing.

L<IRC::Toolkit> for an extensive set of IRC-related utilities.

L<POE::Component::IRC> if you're looking for a mature, fully-featured IRC
client library.

=head1 AUTHOR

Jon Portnoy <avenj@cobaltirc.org>

Inspiration derived from L<POE::Component::Server::IRC::Backend> and
L<POE::Component::IRC> by BINGOS, HINRIK et al.

=cut