X-Git-Url: https://git.netwichtig.de/gitweb/?a=blobdiff_plain;f=lib%2Frbot%2Firc.rb;h=17b7bc3e4b0cee684e9f44a37d1701b10666c744;hb=41276911b95c934dd5f33ccef53b03bc9dd40975;hp=69b5d23917f0808089d0622f94b2ba11991df7de;hpb=5659ef2f6717cd713d5202e54a5c4573eeaa3a45;p=user%2Fhenk%2Fcode%2Fruby%2Frbot.git diff --git a/lib/rbot/irc.rb b/lib/rbot/irc.rb index 69b5d239..17b7bc3e 100644 --- a/lib/rbot/irc.rb +++ b/lib/rbot/irc.rb @@ -1,1576 +1,2038 @@ -#-- vim:sw=2:et -# General TODO list -# * do we want to handle a Channel list for each User telling which -# Channels is the User on (of those the client is on too)? -# We may want this so that when a User leaves all Channels and he hasn't -# sent us privmsgs, we know remove him from the Server @users list -#++ -# :title: IRC module -# -# Basic IRC stuff -# -# This module defines the fundamental building blocks for IRC -# -# Author:: Giuseppe Bilotta (giuseppe.bilotta@gmail.com) -# Copyright:: Copyright (c) 2006 Giuseppe Bilotta -# License:: GPLv2 - -require 'singleton' - - -# The Irc module is used to keep all IRC-related classes -# in the same namespace -# -module Irc - - - # Due to its Scandinavian origins, IRC has strange case mappings, which - # consider the characters {}|^ as the uppercase - # equivalents of # []\~. - # - # This is however not the same on all IRC servers: some use standard ASCII - # casemapping, other do not consider ^ as the uppercase of - # ~ - # - class Casemap - @@casemaps = {} - - # Create a new casemap with name _name_, uppercase characters _upper_ and - # lowercase characters _lower_ - # - def initialize(name, upper, lower) - @key = name.to_sym - raise "Casemap #{name.inspect} already exists!" if @@casemaps.has_key?(@key) - @@casemaps[@key] = { - :upper => upper, - :lower => lower, - :casemap => self - } - end - - # Returns the Casemap with the given name - # - def Casemap.get(name) - @@casemaps[name.to_sym][:casemap] - end - - # Retrieve the 'uppercase characters' of this Casemap - # - def upper - @@casemaps[@key][:upper] - end - - # Retrieve the 'lowercase characters' of this Casemap - # - def lower - @@casemaps[@key][:lower] - end - - # Return a Casemap based on the receiver - # - def to_irc_casemap - self - end - - # A Casemap is represented by its lower/upper mappings - # - def inspect - "#<#{self.class}:#{'0x%x'% self.object_id}: #{upper.inspect} ~(#{self})~ #{lower.inspect}>" - end - - # As a String we return our name - # - def to_s - @key.to_s - end - - # Raise an error if _arg_ and self are not the same Casemap - # - def must_be(arg) - other = arg.to_irc_casemap - raise "Casemap mismatch (#{self} != #{other})" unless self == other - return true - end - - end - - # The rfc1459 casemap - # - class RfcCasemap < Casemap - include Singleton - - def initialize - super('rfc1459', "\x41-\x5e", "\x61-\x7e") - end - - end - RfcCasemap.instance - - # The strict-rfc1459 Casemap - # - class StrictRfcCasemap < Casemap - include Singleton - - def initialize - super('strict-rfc1459', "\x41-\x5d", "\x61-\x7d") - end - - end - StrictRfcCasemap.instance - - # The ascii Casemap - # - class AsciiCasemap < Casemap - include Singleton - - def initialize - super('ascii', "\x41-\x5a", "\x61-\x7a") - end - - end - AsciiCasemap.instance - - - # This module is included by all classes that are either bound to a server - # or should have a casemap. - # - module ServerOrCasemap - - attr_reader :server - - # This method initializes the instance variables @server and @casemap - # according to the values of the hash keys :server and :casemap in _opts_ - # - def init_server_or_casemap(opts={}) - @server = opts.fetch(:server, nil) - raise TypeError, "#{@server} is not a valid Irc::Server" if @server and not @server.kind_of?(Server) - - @casemap = opts.fetch(:casemap, nil) - if @server - if @casemap - @server.casemap.must_be(@casemap) - @casemap = nil - end - else - @casemap = (@casemap || 'rfc1459').to_irc_casemap - end - end - - # This is an auxiliary method: it returns true if the receiver fits the - # server and casemap specified in _opts_, false otherwise. - # - def fits_with_server_and_casemap?(opts={}) - srv = opts.fetch(:server, nil) - cmap = opts.fetch(:casemap, nil) - cmap = cmap.to_irc_casemap unless cmap.nil? - - if srv.nil? - return true if cmap.nil? or cmap == casemap - else - return true if srv == @server and (cmap.nil? or cmap == casemap) - end - return false - end - - # Returns the casemap of the receiver, by looking at the bound - # @server (if possible) or at the @casemap otherwise - # - def casemap - @server.casemap rescue @casemap - end - - # Returns a hash with the current @server and @casemap as values of - # :server and :casemap - # - def server_and_casemap - {:server => @server, :casemap => @casemap} - end - - # We allow up/downcasing with a different casemap - # - def irc_downcase(cmap=casemap) - self.to_s.irc_downcase(cmap) - end - - # Up/downcasing something that includes this module returns its - # Up/downcased to_s form - # - def downcase - self.irc_downcase - end - - # We allow up/downcasing with a different casemap - # - def irc_upcase(cmap=casemap) - self.to_s.irc_upcase(cmap) - end - - # Up/downcasing something that includes this module returns its - # Up/downcased to_s form - # - def upcase - self.irc_upcase - end - - end - -end - - -# We start by extending the String class -# with some IRC-specific methods -# -class String - - # This method returns the Irc::Casemap whose name is the receiver - # - def to_irc_casemap - Irc::Casemap.get(self) rescue raise TypeError, "Unkown Irc::Casemap #{self.inspect}" - end - - # This method returns a string which is the downcased version of the - # receiver, according to the given _casemap_ - # - # - def irc_downcase(casemap='rfc1459') - cmap = casemap.to_irc_casemap - self.tr(cmap.upper, cmap.lower) - end - - # This is the same as the above, except that the string is altered in place - # - # See also the discussion about irc_downcase - # - def irc_downcase!(casemap='rfc1459') - cmap = casemap.to_irc_casemap - self.tr!(cmap.upper, cmap.lower) - end - - # Upcasing functions are provided too - # - # See also the discussion about irc_downcase - # - def irc_upcase(casemap='rfc1459') - cmap = casemap.to_irc_casemap - self.tr(cmap.lower, cmap.upper) - end - - # In-place upcasing - # - # See also the discussion about irc_downcase - # - def irc_upcase!(casemap='rfc1459') - cmap = casemap.to_irc_casemap - self.tr!(cmap.lower, cmap.upper) - end - - # This method checks if the receiver contains IRC glob characters - # - # IRC has a very primitive concept of globs: a * stands for "any - # number of arbitrary characters", a ? stands for "one and exactly - # one arbitrary character". These characters can be escaped by prefixing them - # with a slash (\\). - # - # A known limitation of this glob syntax is that there is no way to escape - # the escape character itself, so it's not possible to build a glob pattern - # where the escape character precedes a glob. - # - def has_irc_glob? - self =~ /^[*?]|[^\\][*?]/ - end - - # This method is used to convert the receiver into a Regular Expression - # that matches according to the IRC glob syntax - # - def to_irc_regexp - regmask = Regexp.escape(self) - regmask.gsub!(/(\\\\)?\\[*?]/) { |m| - case m - when /\\(\\[*?])/ - $1 - when /\\\*/ - '.*' - when /\\\?/ - '.' - else - raise "Unexpected match #{m} when converting #{self}" - end - } - Regexp.new(regmask) - end - -end - - -# ArrayOf is a subclass of Array whose elements are supposed to be all -# of the same class. This is not intended to be used directly, but rather -# to be subclassed as needed (see for example Irc::UserList and Irc::NetmaskList) -# -# Presently, only very few selected methods from Array are overloaded to check -# if the new elements are the correct class. An orthodox? method is provided -# to check the entire ArrayOf against the appropriate class. -# -class ArrayOf < Array - - attr_reader :element_class - - # Create a new ArrayOf whose elements are supposed to be all of type _kl_, - # optionally filling it with the elements from the Array argument. - # - def initialize(kl, ar=[]) - raise TypeError, "#{kl.inspect} must be a class name" unless kl.kind_of?(Class) - super() - @element_class = kl - case ar - when Array - insert(0, *ar) - else - raise TypeError, "#{self.class} can only be initialized from an Array" - end - end - - def inspect - "#<#{self.class}[#{@element_class}]:#{'0x%x' % self.object_id}: #{super}>" - end - - # Private method to check the validity of the elements passed to it - # and optionally raise an error - # - # TODO should it accept nils as valid? - # - def internal_will_accept?(raising, *els) - els.each { |el| - unless el.kind_of?(@element_class) - raise TypeError, "#{el.inspect} is not of class #{@element_class}" if raising - return false - end - } - return true - end - private :internal_will_accept? - - # This method checks if the passed arguments are acceptable for our ArrayOf - # - def will_accept?(*els) - internal_will_accept?(false, *els) - end - - # This method checks that all elements are of the appropriate class - # - def valid? - will_accept?(*self) - end - - # This method is similar to the above, except that it raises an exception - # if the receiver is not valid - # - def validate - raise TypeError unless valid? - end - - # Overloaded from Array#<<, checks for appropriate class of argument - # - def <<(el) - super(el) if internal_will_accept?(true, el) - end - - # Overloaded from Array#&, checks for appropriate class of argument elements - # - def &(ar) - r = super(ar) - ArrayOf.new(@element_class, r) if internal_will_accept?(true, *r) - end - - # Overloaded from Array#+, checks for appropriate class of argument elements - # - def +(ar) - ArrayOf.new(@element_class, super(ar)) if internal_will_accept?(true, *ar) - end - - # Overloaded from Array#-, so that an ArrayOf is returned. There is no need - # to check the validity of the elements in the argument - # - def -(ar) - ArrayOf.new(@element_class, super(ar)) # if internal_will_accept?(true, *ar) - end - - # Overloaded from Array#|, checks for appropriate class of argument elements - # - def |(ar) - ArrayOf.new(@element_class, super(ar)) if internal_will_accept?(true, *ar) - end - - # Overloaded from Array#concat, checks for appropriate class of argument - # elements - # - def concat(ar) - super(ar) if internal_will_accept?(true, *ar) - end - - # Overloaded from Array#insert, checks for appropriate class of argument - # elements - # - def insert(idx, *ar) - super(idx, *ar) if internal_will_accept?(true, *ar) - end - - # Overloaded from Array#replace, checks for appropriate class of argument - # elements - # - def replace(ar) - super(ar) if (ar.kind_of?(ArrayOf) && ar.element_class <= @element_class) or internal_will_accept?(true, *ar) - end - - # Overloaded from Array#push, checks for appropriate class of argument - # elements - # - def push(*ar) - super(*ar) if internal_will_accept?(true, *ar) - end - - # Overloaded from Array#unshift, checks for appropriate class of argument(s) - # - def unshift(*els) - els.each { |el| - super(el) if internal_will_accept?(true, *els) - } - end - - # Modifying methods which we don't handle yet are made private - # - private :[]=, :collect!, :map!, :fill, :flatten! - -end - - -module Irc - - - # A Netmask identifies each user by collecting its nick, username and - # hostname in the form nick!user@host - # - # Netmasks can also contain glob patterns in any of their components; in - # this form they are used to refer to more than a user or to a user - # appearing under different forms. - # - # Example: - # * *!*@* refers to everybody - # * *!someuser@somehost refers to user +someuser+ on host +somehost+ - # regardless of the nick used. - # - class Netmask - - # Netmasks have an associated casemap unless they are bound to a server - # - include ServerOrCasemap - - attr_reader :nick, :user, :host - - # Create a new Netmask from string _str_, which must be in the form - # _nick_!_user_@_host_ - # - # It is possible to specify a server or a casemap in the optional Hash: - # these are used to associate the Netmask with the given server and to set - # its casemap: if a server is specified and a casemap is not, the server's - # casemap is used. If both a server and a casemap are specified, the - # casemap must match the server's casemap or an exception will be raised. - # - # Empty +nick+, +user+ or +host+ are converted to the generic glob pattern - # - def initialize(str="", opts={}) - debug "String: #{str.inspect}, options: #{opts.inspect}" - - # First of all, check for server/casemap option - # - init_server_or_casemap(opts) - - # Now we can see if the given string _str_ is an actual Netmask - if str.respond_to?(:to_str) - case str.to_str - when /^(?:(\S+?)(?:!(\S+)@(?:(\S+))?)?)?$/ - # We do assignment using our internal methods - self.nick = $1 - self.user = $2 - self.host = $3 - else - raise ArgumentError, "#{str.to_str.inspect} does not represent a valid #{self.class}" - end - else - raise TypeError, "#{str} cannot be converted to a #{self.class}" - end - end - - # A Netmask is easily converted to a String for the usual representation - # - def fullform - "#{nick}!#{user}@#{host}" - end - alias :to_s :fullform - - # Converts the receiver into a Netmask with the given (optional) - # server/casemap association. We return self unless a conversion - # is needed (different casemap/server) - # - # Subclasses of Netmask will return a new Netmask - # - def to_irc_netmask(opts={}) - if self.class == Netmask - return self if fits_with_server_and_casemap?(opts) - end - return self.fullform.to_irc_netmask(opts) - end - - # Converts the receiver into a User with the given (optional) - # server/casemap association. We return self unless a conversion - # is needed (different casemap/server) - # - def to_irc_user(opts={}) - self.fullform.to_irc_user(opts) - end - - # Inspection of a Netmask reveals the server it's bound to (if there is - # one), its casemap and the nick, user and host part - # - def inspect - str = "<#{self.class}:#{'0x%x' % self.object_id}:" - str << " @server=#{@server}" if @server - str << " @nick=#{@nick.inspect} @user=#{@user.inspect}" - str << " @host=#{@host.inspect} casemap=#{casemap.inspect}>" - str - end - - # Equality: two Netmasks are equal if they downcase to the same thing - # - # TODO we may want it to try other.to_irc_netmask - # - def ==(other) - return false unless other.kind_of?(self.class) - self.downcase == other.downcase - end - - # This method changes the nick of the Netmask, defaulting to the generic - # glob pattern if the result is the null string. - # - def nick=(newnick) - @nick = newnick.to_s - @nick = "*" if @nick.empty? - end - - # This method changes the user of the Netmask, defaulting to the generic - # glob pattern if the result is the null string. - # - def user=(newuser) - @user = newuser.to_s - @user = "*" if @user.empty? - end - - # This method changes the hostname of the Netmask, defaulting to the generic - # glob pattern if the result is the null string. - # - def host=(newhost) - @host = newhost.to_s - @host = "*" if @host.empty? - end - - # We can replace everything at once with data from another Netmask - # - def replace(other) - case other - when Netmask - nick = other.nick - user = other.user - host = other.host - @server = other.server - @casemap = other.casemap unless @server - else - replace(other.to_irc_netmask(server_and_casemap)) - end - end - - # This method checks if a Netmask is definite or not, by seeing if - # any of its components are defined by globs - # - def has_irc_glob? - return @nick.has_irc_glob? || @user.has_irc_glob? || @host.has_irc_glob? - end - - # This method is used to match the current Netmask against another one - # - # The method returns true if each component of the receiver matches the - # corresponding component of the argument. By _matching_ here we mean - # that any netmask described by the receiver is also described by the - # argument. - # - # In this sense, matching is rather simple to define in the case when the - # receiver has no globs: it is just necessary to check if the argument - # describes the receiver, which can be done by matching it against the - # argument converted into an IRC Regexp (see String#to_irc_regexp). - # - # The situation is also easy when the receiver has globs and the argument - # doesn't, since in this case the result is false. - # - # The more complex case in which both the receiver and the argument have - # globs is not handled yet. - # - def matches?(arg) - cmp = arg.to_irc_netmask(:casemap => casemap) - [:nick, :user, :host].each { |component| - us = self.send(component).irc_downcase(casemap) - them = cmp.send(component).irc_downcase(casemap) - raise NotImplementedError if us.has_irc_glob? && them.has_irc_glob? - return false if us.has_irc_glob? && !them.has_irc_glob? - return false unless us =~ them.to_irc_regexp - } - return true - end - - # Case equality. Checks if arg matches self - # - def ===(arg) - arg.to_irc_netmask(:casemap => casemap).matches?(self) - end - - # Sorting is done via the fullform - # - def <=>(arg) - case arg - when Netmask - self.fullform.irc_downcase(casemap) <=> arg.fullform.irc_downcase(casemap) - else - self.downcase <=> arg.downcase - end - end - - end - - - # A NetmaskList is an ArrayOf Netmasks - # - class NetmaskList < ArrayOf - - # Create a new NetmaskList, optionally filling it with the elements from - # the Array argument fed to it. - # - def initialize(ar=[]) - super(Netmask, ar) - end - - end - -end - -class String - - # We keep extending String, this time adding a method that converts a - # String into an Irc::Netmask object - # - def to_irc_netmask(opts={}) - Irc::Netmask.new(self, opts) - end - -end - - -module Irc - - - # An IRC User is identified by his/her Netmask (which must not have globs). - # In fact, User is just a subclass of Netmask. - # - # Ideally, the user and host information of an IRC User should never - # change, and it shouldn't contain glob patterns. However, IRC is somewhat - # idiosincratic and it may be possible to know the nick of a User much before - # its user and host are known. Moreover, some networks (namely Freenode) may - # change the hostname of a User when (s)he identifies with Nickserv. - # - # As a consequence, we must allow changes to a User host and user attributes. - # We impose a restriction, though: they may not contain glob patterns, except - # for the special case of an unknown user/host which is represented by a *. - # - # It is possible to create a totally unknown User (e.g. for initializations) - # by setting the nick to * too. - # - # TODO list: - # * see if it's worth to add the other USER data - # * see if it's worth to add NICKSERV status - # - class User < Netmask - alias :to_s :nick - - # Create a new IRC User from a given Netmask (or anything that can be converted - # into a Netmask) provided that the given Netmask does not have globs. - # - def initialize(str="", opts={}) - debug "String: #{str.inspect}, options: #{opts.inspect}" - super - raise ArgumentError, "#{str.inspect} must not have globs (unescaped * or ?)" if nick.has_irc_glob? && nick != "*" - raise ArgumentError, "#{str.inspect} must not have globs (unescaped * or ?)" if user.has_irc_glob? && user != "*" - raise ArgumentError, "#{str.inspect} must not have globs (unescaped * or ?)" if host.has_irc_glob? && host != "*" - @away = false - end - - # The nick of a User may be changed freely, but it must not contain glob patterns. - # - def nick=(newnick) - raise "Can't change the nick to #{newnick}" if defined?(@nick) and newnick.has_irc_glob? - super - end - - # We have to allow changing the user of an Irc User due to some networks - # (e.g. Freenode) changing hostmasks on the fly. We still check if the new - # user data has glob patterns though. - # - def user=(newuser) - raise "Can't change the username to #{newuser}" if defined?(@user) and newuser.has_irc_glob? - super - end - - # We have to allow changing the host of an Irc User due to some networks - # (e.g. Freenode) changing hostmasks on the fly. We still check if the new - # host data has glob patterns though. - # - def host=(newhost) - raise "Can't change the hostname to #{newhost}" if defined?(@host) and newhost.has_irc_glob? - super - end - - # Checks if a User is well-known or not by looking at the hostname and user - # - def known? - return nick!= "*" && user!="*" && host!="*" - end - - # Is the user away? - # - def away? - return @away - end - - # Set the away status of the user. Use away=(nil) or away=(false) - # to unset away - # - def away=(msg="") - if msg - @away = msg - else - @away = false - end - end - - # Since to_irc_user runs the same checks on server and channel as - # to_irc_netmask, we just try that and return self if it works. - # - # Subclasses of User will return self if possible. - # - def to_irc_user(opts={}) - return self if fits_with_server_and_casemap?(opts) - return self.fullform.to_irc_user(opts) - end - - # We can replace everything at once with data from another User - # - def replace(other) - case other - when User - nick = other.nick - user = other.user - host = other.host - @server = other.server - @casemap = other.casemap unless @server - @away = other.away - else - replace(other.to_irc_user(server_and_casemap)) - end - end - - end - - - # A UserList is an ArrayOf Users - # - class UserList < ArrayOf - - # Create a new UserList, optionally filling it with the elements from - # the Array argument fed to it. - # - def initialize(ar=[]) - super(User, ar) - end - - end - -end - -class String - - # We keep extending String, this time adding a method that converts a - # String into an Irc::User object - # - def to_irc_user(opts={}) - debug "opts = #{opts.inspect}" - Irc::User.new(self, opts) - end - -end - -module Irc - - # An IRC Channel is identified by its name, and it has a set of properties: - # * a Channel::Topic - # * a UserList - # * a set of Channel::Modes - # - # The Channel::Topic and Channel::Mode classes are defined within the - # Channel namespace because they only make sense there - # - class Channel - - - # Mode on a Channel - # - class Mode - def initialize(ch) - @channel = ch - end - - end - - - # Channel modes of type A manipulate lists - # - class ModeTypeA < Mode - def initialize(ch) - super - @list = NetmaskList.new - end - - def set(val) - nm = @channel.server.new_netmask(val) - @list << nm unless @list.include?(nm) - end - - def reset(val) - nm = @channel.server.new_netmask(val) - @list.delete(nm) - end - - end - - - # Channel modes of type B need an argument - # - class ModeTypeB < Mode - def initialize(ch) - super - @arg = nil - end - - def set(val) - @arg = val - end - - def reset(val) - @arg = nil if @arg == val - end - - end - - - # Channel modes that change the User prefixes are like - # Channel modes of type B, except that they manipulate - # lists of Users, so they are somewhat similar to channel - # modes of type A - # - class UserMode < ModeTypeB - def initialize(ch) - super - @list = UserList.new - end - - def set(val) - u = @channel.server.user(val) - @list << u unless @list.include?(u) - end - - def reset(val) - u = @channel.server.user(val) - @list.delete(u) - end - - end - - - # Channel modes of type C need an argument when set, - # but not when they get reset - # - class ModeTypeC < Mode - def initialize(ch) - super - @arg = false - end - - def status - @arg - end - - def set(val) - @arg = val - end - - def reset - @arg = false - end - - end - - - # Channel modes of type D are basically booleans - # - class ModeTypeD < Mode - def initialize(ch) - super - @set = false - end - - def set? - return @set - end - - def set - @set = true - end - - def reset - @set = false - end - - end - - - # A Topic represents the topic of a channel. It consists of - # the topic itself, who set it and when - # - class Topic - attr_accessor :text, :set_by, :set_on - alias :to_s :text - - # Create a new Topic setting the text, the creator and - # the creation time - # - def initialize(text="", set_by="", set_on=Time.new) - @text = text - @set_by = set_by.to_irc_user - @set_on = set_on - end - - # Replace a Topic with another one - # - def replace(topic) - raise TypeError, "#{topic.inspect} is not of class #{self.class}" unless topic.kind_of?(self.class) - @text = topic.text.dup - @set_by = topic.set_by.dup - @set_on = topic.set_on.dup - end - - # Returns self - # - def to_irc_channel_topic - self - end - - end - - end - -end - - -class String - - # Returns an Irc::Channel::Topic with self as text - # - def to_irc_channel_topic - Irc::Channel::Topic.new(self) - end - -end - - -module Irc - - - # Here we start with the actual Channel class - # - class Channel - - include ServerOrCasemap - attr_reader :name, :topic, :mode, :users - alias :to_s :name - - def inspect - str = "<#{self.class}:#{'0x%x' % self.object_id}:" - str << " on server #{server}" if server - str << " @name=#{@name.inspect} @topic=#{@topic.text.inspect}" - str << " @users=<#{@users.sort.join(', ')}>" - str - end - - # Returns self - # - def to_irc_channel - self - end - - # Creates a new channel with the given name, optionally setting the topic - # and an initial users list. - # - # No additional info is created here, because the channel flags and userlists - # allowed depend on the server. - # - def initialize(name, topic=nil, users=[], opts={}) - raise ArgumentError, "Channel name cannot be empty" if name.to_s.empty? - warn "Unknown channel prefix #{name[0].chr}" if name !~ /^[&#+!]/ - raise ArgumentError, "Invalid character in #{name.inspect}" if name =~ /[ \x07,]/ - - init_server_or_casemap(opts) - - @name = name - - @topic = (topic.to_irc_channel_topic rescue Channel::Topic.new) - - @users = UserList.new - - users.each { |u| - @users << u.to_irc_user(server_and_casemap) - } - - # Flags - @mode = {} - end - - # Removes a user from the channel - # - def delete_user(user) - @mode.each { |sym, mode| - mode.reset(user) if mode.kind_of?(UserMode) - } - @users.delete(user) - end - - # The channel prefix - # - def prefix - name[0].chr - end - - # A channel is local to a server if it has the '&' prefix - # - def local? - name[0] = 0x26 - end - - # A channel is modeless if it has the '+' prefix - # - def modeless? - name[0] = 0x2b - end - - # A channel is safe if it has the '!' prefix - # - def safe? - name[0] = 0x21 - end - - # A channel is normal if it has the '#' prefix - # - def normal? - name[0] = 0x23 - end - - # Create a new mode - # - def create_mode(sym, kl) - @mode[sym.to_sym] = kl.new(self) - end - - end - - - # A ChannelList is an ArrayOf Channels - # - class ChannelList < ArrayOf - - # Create a new ChannelList, optionally filling it with the elements from - # the Array argument fed to it. - # - def initialize(ar=[]) - super(Channel, ar) - end - - end - -end - - -class String - - # We keep extending String, this time adding a method that converts a - # String into an Irc::Channel object - # - def to_irc_channel(opts={}) - Irc::Channel.new(self, opts) - end - -end - - -module Irc - - - # An IRC Server represents the Server the client is connected to. - # - class Server - - attr_reader :hostname, :version, :usermodes, :chanmodes - alias :to_s :hostname - attr_reader :supports, :capabilities - - attr_reader :channels, :users - - def channel_names - @channels.map { |ch| ch.downcase } - end - - def user_nicks - @users.map { |u| u.downcase } - end - - def inspect - chans, users = [@channels, @users].map {|d| - d.sort { |a, b| - a.downcase <=> b.downcase - }.map { |x| - x.inspect - } - } - - str = "<#{self.class}:#{'0x%x' % self.object_id}:" - str << " @hostname=#{hostname}" - str << " @channels=#{chans}" - str << " @users=#{users}>" - str - end - - # Create a new Server, with all instance variables reset to nil (for - # scalar variables), empty channel and user lists and @supports - # initialized to the default values for all known supported features. - # - def initialize - @hostname = @version = @usermodes = @chanmodes = nil - - @channels = ChannelList.new - - @users = UserList.new - - reset_capabilities - end - - # Resets the server capabilities - # - def reset_capabilities - @supports = { - :casemapping => 'rfc1459', - :chanlimit => {}, - :chanmodes => { - :typea => nil, # Type A: address lists - :typeb => nil, # Type B: needs a parameter - :typec => nil, # Type C: needs a parameter when set - :typed => nil # Type D: must not have a parameter - }, - :channellen => 200, - :chantypes => "#&", - :excepts => nil, - :idchan => {}, - :invex => nil, - :kicklen => nil, - :maxlist => {}, - :modes => 3, - :network => nil, - :nicklen => 9, - :prefix => { - :modes => 'ov'.scan(/./), - :prefixes => '@+'.scan(/./) - }, - :safelist => nil, - :statusmsg => nil, - :std => nil, - :targmax => {}, - :topiclen => nil - } - @capabilities = {} - end - - # Resets the Channel and User list - # - def reset_lists - @users.each { |u| - delete_user(u) - } - @channels.each { |u| - delete_channel(u) - } - end - - # Clears the server - # - def clear - reset_lists - reset_capabilities - end - - # This method is used to parse a 004 RPL_MY_INFO line - # - def parse_my_info(line) - ar = line.split(' ') - @hostname = ar[0] - @version = ar[1] - @usermodes = ar[2] - @chanmodes = ar[3] - end - - def noval_warn(key, val, &block) - if val - yield if block_given? - else - warn "No #{key.to_s.upcase} value" - end - end - - def val_warn(key, val, &block) - if val == true or val == false or val.nil? - yield if block_given? - else - warn "No #{key.to_s.upcase} value must be specified, got #{val}" - end - end - private :noval_warn, :val_warn - - # This method is used to parse a 005 RPL_ISUPPORT line - # - # See the RPL_ISUPPORT draft[http://www.irc.org/tech_docs/draft-brocklesby-irc-isupport-03.txt] - # - def parse_isupport(line) - debug "Parsing ISUPPORT #{line.inspect}" - ar = line.split(' ') - reparse = "" - ar.each { |en| - prekey, val = en.split('=', 2) - if prekey =~ /^-(.*)/ - key = $1.downcase.to_sym - val = false - else - key = prekey.downcase.to_sym - end - case key - when :casemapping, :network - noval_warn(key, val) { - @supports[key] = val - } - when :chanlimit, :idchan, :maxlist, :targmax - noval_warn(key, val) { - groups = val.split(',') - groups.each { |g| - k, v = g.split(':') - @supports[key][k] = v.to_i - } - } - when :maxchannels - noval_warn(key, val) { - reparse += "CHANLIMIT=(chantypes):#{val} " - } - when :maxtargets - noval_warn(key, val) { - @supports[key]['PRIVMSG'] = val.to_i - @supports[key]['NOTICE'] = val.to_i - } - when :chanmodes - noval_warn(key, val) { - groups = val.split(',') - @supports[key][:typea] = groups[0].scan(/./).map { |x| x.to_sym} - @supports[key][:typeb] = groups[1].scan(/./).map { |x| x.to_sym} - @supports[key][:typec] = groups[2].scan(/./).map { |x| x.to_sym} - @supports[key][:typed] = groups[3].scan(/./).map { |x| x.to_sym} - } - when :channellen, :kicklen, :modes, :topiclen - if val - @supports[key] = val.to_i - else - @supports[key] = nil - end - when :chantypes - @supports[key] = val # can also be nil - when :excepts - val ||= 'e' - @supports[key] = val - when :invex - val ||= 'I' - @supports[key] = val - when :nicklen - noval_warn(key, val) { - @supports[key] = val.to_i - } - when :prefix - if val - val.scan(/\((.*)\)(.*)/) { |m, p| - @supports[key][:modes] = m.scan(/./).map { |x| x.to_sym} - @supports[key][:prefixes] = p.scan(/./).map { |x| x.to_sym} - } - else - @supports[key][:modes] = nil - @supports[key][:prefixes] = nil - end - when :safelist - val_warn(key, val) { - @supports[key] = val.nil? ? true : val - } - when :statusmsg - noval_warn(key, val) { - @supports[key] = val.scan(/./) - } - when :std - noval_warn(key, val) { - @supports[key] = val.split(',') - } - else - @supports[key] = val.nil? ? true : val - end - } - reparse.gsub!("(chantypes)",@supports[:chantypes]) - parse_isupport(reparse) unless reparse.empty? - end - - # Returns the casemap of the server. - # - def casemap - @supports[:casemapping] - end - - # Returns User or Channel depending on what _name_ can be - # a name of - # - def user_or_channel?(name) - if supports[:chantypes].include?(name[0]) - return Channel - else - return User - end - end - - # Returns the actual User or Channel object matching _name_ - # - def user_or_channel(name) - if supports[:chantypes].include?(name[0]) - return channel(name) - else - return user(name) - end - end - - # Checks if the receiver already has a channel with the given _name_ - # - def has_channel?(name) - channel_names.index(name.downcase) - end - alias :has_chan? :has_channel? - - # Returns the channel with name _name_, if available - # - def get_channel(name) - idx = has_channel?(name) - channels[idx] if idx - end - alias :get_chan :get_channel - - # Create a new Channel object bound to the receiver and add it to the - # list of Channels on the receiver, unless the channel was - # present already. In this case, the default action is to raise an - # exception, unless _fails_ is set to false - # - def new_channel(name, topic=nil, users=[], fails=true) - ex = get_chan(name) - if ex - raise "Channel #{name} already exists on server #{self}" if fails - return ex - else - - prefix = name[0].chr - - # Give a warning if the new Channel goes over some server limits. - # - # FIXME might need to raise an exception - # - warn "#{self} doesn't support channel prefix #{prefix}" unless @supports[:chantypes].include?(prefix) - warn "#{self} doesn't support channel names this long (#{name.length} > #{@supports[:channellen]})" unless name.length <= @supports[:channellen] - - # Next, we check if we hit the limit for channels of type +prefix+ - # if the server supports +chanlimit+ - # - @supports[:chanlimit].keys.each { |k| - next unless k.include?(prefix) - count = 0 - channel_names.each { |n| - count += 1 if k.include?(n[0]) - } - raise IndexError, "Already joined #{count} channels with prefix #{k}" if count == @supports[:chanlimit][k] - } - - # So far, everything is fine. Now create the actual Channel - # - chan = Channel.new(name, topic, users, :server => self) - - # We wade through +prefix+ and +chanmodes+ to create appropriate - # lists and flags for this channel - - @supports[:prefix][:modes].each { |mode| - chan.create_mode(mode, Channel::UserMode) - } if @supports[:prefix][:modes] - - @supports[:chanmodes].each { |k, val| - if val - case k - when :typea - val.each { |mode| - chan.create_mode(mode, Channel::ModeTypeA) - } - when :typeb - val.each { |mode| - chan.create_mode(mode, Channel::ModeTypeB) - } - when :typec - val.each { |mode| - chan.create_mode(mode, Channel::ModeTypeC) - } - when :typed - val.each { |mode| - chan.create_mode(mode, Channel::ModeTypeD) - } - end - end - } - - @channels << chan - # debug "Created channel #{chan.inspect}" - return chan - end - end - - # Returns the Channel with the given _name_ on the server, - # creating it if necessary. This is a short form for - # new_channel(_str_, nil, [], +false+) - # - def channel(str) - new_channel(str,nil,[],false) - end - - # Remove Channel _name_ from the list of Channels - # - def delete_channel(name) - idx = has_channel?(name) - raise "Tried to remove unmanaged channel #{name}" unless idx - @channels.delete_at(idx) - end - - # Checks if the receiver already has a user with the given _nick_ - # - def has_user?(nick) - user_nicks.index(nick.downcase) - end - - # Returns the user with nick _nick_, if available - # - def get_user(nick) - idx = has_user?(nick) - @users[idx] if idx - end - - # Create a new User object bound to the receiver and add it to the list - # of Users on the receiver, unless the User was present - # already. In this case, the default action is to raise an exception, - # unless _fails_ is set to false - # - def new_user(str, fails=true) - tmp = str.to_irc_user(:server => self) - old = get_user(tmp.nick) - if old - # debug "User already existed as #{old.inspect}" - if tmp.known? - if old.known? - # Do not raise an error: things like Freenode change the hostname after identification - warning "User #{tmp.nick} has inconsistent Netmasks! #{self} knows #{old.inspect} but access was tried with #{tmp.inspect}" if old != tmp - raise "User #{tmp} already exists on server #{self}" if fails - end - if old != tmp - old.replace(tmp) - # debug "User improved to #{old.inspect}" - end - end - return old - else - warn "#{self} doesn't support nicknames this long (#{tmp.nick.length} > #{@supports[:nicklen]})" unless tmp.nick.length <= @supports[:nicklen] - @users << tmp - return @users.last - end - end - - # Returns the User with the given Netmask on the server, - # creating it if necessary. This is a short form for - # new_user(_str_, +false+) - # - def user(str) - new_user(str, false) - end - - # Deletes User _user_ from Channel _channel_ - # - def delete_user_from_channel(user, channel) - channel.delete_user(user) - end - - # Remove User _someuser_ from the list of Users. - # _someuser_ must be specified with the full Netmask. - # - def delete_user(someuser) - idx = has_user?(someuser) - raise "Tried to remove unmanaged user #{user}" unless idx - have = self.user(someuser) - @channels.each { |ch| - delete_user_from_channel(have, ch) - } - @users.delete_at(idx) - end - - # Create a new Netmask object with the appropriate casemap - # - def new_netmask(str) - str.to_irc_netmask(:server => self) - end - - # Finds all Users on server whose Netmask matches _mask_ - # - def find_users(mask) - nm = new_netmask(mask) - @users.inject(UserList.new) { - |list, user| - if user.user == "*" or user.host == "*" - list << user if user.nick.downcase =~ nm.nick.downcase.to_irc_regexp - else - list << user if user.matches?(nm) - end - list - } - end - - end - -end - +#-- vim:sw=2:et +# General TODO list +# * do we want to handle a Channel list for each User telling which +# Channels is the User on (of those the client is on too)? +# We may want this so that when a User leaves all Channels and he hasn't +# sent us privmsgs, we know we can remove him from the Server @users list +# FIXME for the time being, we do it with a method that scans the server +# (if defined), so the method is slow and should not be used frequently. +# * Maybe ChannelList and UserList should be HashesOf instead of ArrayOf? +# See items marked as TODO Ho. +# The framework to do this is now in place, thanks to the new [] method +# for NetmaskList, which allows retrieval by Netmask or String +#++ +# :title: IRC module +# +# Basic IRC stuff +# +# This module defines the fundamental building blocks for IRC +# +# Author:: Giuseppe Bilotta (giuseppe.bilotta@gmail.com) + +require 'singleton' + +# The following monkeypatch is to fix a bug in Singleton where marshaling would +# fail when trying to restore a marshaled Singleton due to _load being declared +# private. +if RUBY_VERSION < '1.9' +module ::Singleton + public :_dump +end + +class << Singleton + module SingletonClassMethods + public :_load + end +end +end + +class Object + + # We extend the Object class with a method that + # checks if the receiver is nil or empty + def nil_or_empty? + return true unless self + return true if self.respond_to? :empty? and self.empty? + return false + end + + # We alias the to_s method to __to_s__ to make + # it accessible in all classes + alias :__to_s__ :to_s +end + +# The Irc module is used to keep all IRC-related classes +# in the same namespace +# +module Irc + + + # Due to its Scandinavian origins, IRC has strange case mappings, which + # consider the characters {}|^ as the uppercase + # equivalents of # []\~. + # + # This is however not the same on all IRC servers: some use standard ASCII + # casemapping, other do not consider ^ as the uppercase of + # ~ + # + class Casemap + @@casemaps = {} + + # Create a new casemap with name _name_, uppercase characters _upper_ and + # lowercase characters _lower_ + # + def initialize(name, upper, lower) + @key = name.to_sym + raise "Casemap #{name.inspect} already exists!" if @@casemaps.has_key?(@key) + @@casemaps[@key] = { + :upper => upper, + :lower => lower, + :casemap => self + } + end + + # Returns the Casemap with the given name + # + def Casemap.get(name) + @@casemaps[name.to_sym][:casemap] + end + + # Retrieve the 'uppercase characters' of this Casemap + # + def upper + @@casemaps[@key][:upper] + end + + # Retrieve the 'lowercase characters' of this Casemap + # + def lower + @@casemaps[@key][:lower] + end + + # Return a Casemap based on the receiver + # + def to_irc_casemap + self + end + + # A Casemap is represented by its lower/upper mappings + # + def inspect + self.__to_s__[0..-2] + " #{upper.inspect} ~(#{self})~ #{lower.inspect}>" + end + + # As a String we return our name + # + def to_s + @key.to_s + end + + # Two Casemaps are equal if they have the same upper and lower ranges + # + def ==(arg) + other = arg.to_irc_casemap + return self.upper == other.upper && self.lower == other.lower + end + + # Give a warning if _arg_ and self are not the same Casemap + # + def must_be(arg) + other = arg.to_irc_casemap + if self == other + return true + else + warning "Casemap mismatch (#{self.inspect} != #{other.inspect})" + return false + end + end + + end + + # The rfc1459 casemap + # + class RfcCasemap < Casemap + include Singleton + + def initialize + super('rfc1459', "\x41-\x5a\x7b-\x7e", "\x61-\x7a\x5b-\x5e") + end + + end + RfcCasemap.instance + + # The strict-rfc1459 Casemap + # + class StrictRfcCasemap < Casemap + include Singleton + + def initialize + super('strict-rfc1459', "\x41-\x5a\x7b-\x7d", "\x61-\x7a\x5b-\x5d") + end + + end + StrictRfcCasemap.instance + + # The ascii Casemap + # + class AsciiCasemap < Casemap + include Singleton + + def initialize + super('ascii', "\x41-\x5a", "\x61-\x7a") + end + + end + AsciiCasemap.instance + + + # This module is included by all classes that are either bound to a server + # or should have a casemap. + # + module ServerOrCasemap + + attr_reader :server + + # This method initializes the instance variables @server and @casemap + # according to the values of the hash keys :server and :casemap in _opts_ + # + def init_server_or_casemap(opts={}) + @server = opts.fetch(:server, nil) + raise TypeError, "#{@server} is not a valid Irc::Server" if @server and not @server.kind_of?(Server) + + @casemap = opts.fetch(:casemap, nil) + if @server + if @casemap + @server.casemap.must_be(@casemap) + @casemap = nil + end + else + warning 'casemap fallback to rfc1459 without hints, correct?' + @casemap = (@casemap || 'rfc1459').to_irc_casemap + end + end + + # This is an auxiliary method: it returns true if the receiver fits the + # server and casemap specified in _opts_, false otherwise. + # + def fits_with_server_and_casemap?(opts={}) + srv = opts.fetch(:server, nil) + cmap = opts.fetch(:casemap, nil) + cmap = cmap.to_irc_casemap unless cmap.nil? + + if srv.nil? + return true if cmap.nil? or cmap == casemap + else + return true if srv == @server and (cmap.nil? or cmap == casemap) + end + return false + end + + # Returns the casemap of the receiver, by looking at the bound + # @server (if possible) or at the @casemap otherwise + # + def casemap + return @server.casemap if defined?(@server) and @server + return @casemap + end + + # Returns a hash with the current @server and @casemap as values of + # :server and :casemap + # + def server_and_casemap + h = {} + h[:server] = @server if defined?(@server) and @server + h[:casemap] = @casemap if defined?(@casemap) and @casemap + return h + end + + # We allow up/downcasing with a different casemap + # + def irc_downcase(cmap=casemap) + self.to_s.irc_downcase(cmap) + end + + # Up/downcasing something that includes this module returns its + # Up/downcased to_s form + # + def downcase + self.irc_downcase + end + + # We allow up/downcasing with a different casemap + # + def irc_upcase(cmap=casemap) + self.to_s.irc_upcase(cmap) + end + + # Up/downcasing something that includes this module returns its + # Up/downcased to_s form + # + def upcase + self.irc_upcase + end + + end + +end + + +# We start by extending the String class +# with some IRC-specific methods +# +class String + + # This method returns the Irc::Casemap whose name is the receiver + # + def to_irc_casemap + begin + Irc::Casemap.get(self) + rescue + # raise TypeError, "Unkown Irc::Casemap #{self.inspect}" + error "Unkown Irc::Casemap #{self.inspect} requested, defaulting to rfc1459" + Irc::Casemap.get('rfc1459') + end + end + + # This method returns a string which is the downcased version of the + # receiver, according to the given _casemap_ + # + # + def irc_downcase(casemap='rfc1459') + cmap = casemap.to_irc_casemap + self.tr(cmap.upper, cmap.lower) + end + + # This is the same as the above, except that the string is altered in place + # + # See also the discussion about irc_downcase + # + def irc_downcase!(casemap='rfc1459') + cmap = casemap.to_irc_casemap + self.tr!(cmap.upper, cmap.lower) + end + + # Upcasing functions are provided too + # + # See also the discussion about irc_downcase + # + def irc_upcase(casemap='rfc1459') + cmap = casemap.to_irc_casemap + self.tr(cmap.lower, cmap.upper) + end + + # In-place upcasing + # + # See also the discussion about irc_downcase + # + def irc_upcase!(casemap='rfc1459') + cmap = casemap.to_irc_casemap + self.tr!(cmap.lower, cmap.upper) + end + + # This method checks if the receiver contains IRC glob characters + # + # IRC has a very primitive concept of globs: a * stands for "any + # number of arbitrary characters", a ? stands for "one and exactly + # one arbitrary character". These characters can be escaped by prefixing them + # with a slash (\\). + # + # A known limitation of this glob syntax is that there is no way to escape + # the escape character itself, so it's not possible to build a glob pattern + # where the escape character precedes a glob. + # + def has_irc_glob? + self =~ /^[*?]|[^\\][*?]/ + end + + # This method is used to convert the receiver into a Regular Expression + # that matches according to the IRC glob syntax + # + def to_irc_regexp + regmask = Regexp.escape(self) + regmask.gsub!(/(\\\\)?\\[*?]/) { |m| + case m + when /\\(\\[*?])/ + $1 + when /\\\*/ + '.*' + when /\\\?/ + '.' + else + raise "Unexpected match #{m} when converting #{self}" + end + } + Regexp.new("^#{regmask}$") + end + +end + + +# ArrayOf is a subclass of Array whose elements are supposed to be all +# of the same class. This is not intended to be used directly, but rather +# to be subclassed as needed (see for example Irc::UserList and Irc::NetmaskList) +# +# Presently, only very few selected methods from Array are overloaded to check +# if the new elements are the correct class. An orthodox? method is provided +# to check the entire ArrayOf against the appropriate class. +# +class ArrayOf < Array + + attr_reader :element_class + + # Create a new ArrayOf whose elements are supposed to be all of type _kl_, + # optionally filling it with the elements from the Array argument. + # + def initialize(kl, ar=[]) + raise TypeError, "#{kl.inspect} must be a class name" unless kl.kind_of?(Class) + super() + @element_class = kl + case ar + when Array + insert(0, *ar) + else + raise TypeError, "#{self.class} can only be initialized from an Array" + end + end + + def inspect + self.__to_s__[0..-2].sub(/:[^:]+$/,"[#{@element_class}]\\0") + " #{super}>" + end + + # Private method to check the validity of the elements passed to it + # and optionally raise an error + # + # TODO should it accept nils as valid? + # + def internal_will_accept?(raising, *els) + els.each { |el| + unless el.kind_of?(@element_class) + raise TypeError, "#{el.inspect} is not of class #{@element_class}" if raising + return false + end + } + return true + end + private :internal_will_accept? + + # This method checks if the passed arguments are acceptable for our ArrayOf + # + def will_accept?(*els) + internal_will_accept?(false, *els) + end + + # This method checks that all elements are of the appropriate class + # + def valid? + will_accept?(*self) + end + + # This method is similar to the above, except that it raises an exception + # if the receiver is not valid + # + def validate + raise TypeError unless valid? + end + + # Overloaded from Array#<<, checks for appropriate class of argument + # + def <<(el) + super(el) if internal_will_accept?(true, el) + end + + # Overloaded from Array#&, checks for appropriate class of argument elements + # + def &(ar) + r = super(ar) + ArrayOf.new(@element_class, r) if internal_will_accept?(true, *r) + end + + # Overloaded from Array#+, checks for appropriate class of argument elements + # + def +(ar) + ArrayOf.new(@element_class, super(ar)) if internal_will_accept?(true, *ar) + end + + # Overloaded from Array#-, so that an ArrayOf is returned. There is no need + # to check the validity of the elements in the argument + # + def -(ar) + ArrayOf.new(@element_class, super(ar)) # if internal_will_accept?(true, *ar) + end + + # Overloaded from Array#|, checks for appropriate class of argument elements + # + def |(ar) + ArrayOf.new(@element_class, super(ar)) if internal_will_accept?(true, *ar) + end + + # Overloaded from Array#concat, checks for appropriate class of argument + # elements + # + def concat(ar) + super(ar) if internal_will_accept?(true, *ar) + end + + # Overloaded from Array#insert, checks for appropriate class of argument + # elements + # + def insert(idx, *ar) + super(idx, *ar) if internal_will_accept?(true, *ar) + end + + # Overloaded from Array#replace, checks for appropriate class of argument + # elements + # + def replace(ar) + super(ar) if (ar.kind_of?(ArrayOf) && ar.element_class <= @element_class) or internal_will_accept?(true, *ar) + end + + # Overloaded from Array#push, checks for appropriate class of argument + # elements + # + def push(*ar) + super(*ar) if internal_will_accept?(true, *ar) + end + + # Overloaded from Array#unshift, checks for appropriate class of argument(s) + # + def unshift(*els) + els.each { |el| + super(el) if internal_will_accept?(true, *els) + } + end + + # We introduce the 'downcase' method, which maps downcase() to all the Array + # elements, properly failing when the elements don't have a downcase method + # + def downcase + self.map { |el| el.downcase } + end + + # Modifying methods which we don't handle yet are made private + # + private :[]=, :collect!, :map!, :fill, :flatten! + +end + + +# We extend the Regexp class with an Irc module which will contain some +# Irc-specific regexps +# +class Regexp + + # We start with some general-purpose ones which will be used in the + # Irc module too, but are useful regardless + DIGITS = /\d+/ + HEX_DIGIT = /[0-9A-Fa-f]/ + HEX_DIGITS = /#{HEX_DIGIT}+/ + HEX_OCTET = /#{HEX_DIGIT}#{HEX_DIGIT}?/ + DEC_OCTET = /[01]?\d?\d|2[0-4]\d|25[0-5]/ + DEC_IP_ADDR = /#{DEC_OCTET}\.#{DEC_OCTET}\.#{DEC_OCTET}\.#{DEC_OCTET}/ + HEX_IP_ADDR = /#{HEX_OCTET}\.#{HEX_OCTET}\.#{HEX_OCTET}\.#{HEX_OCTET}/ + IP_ADDR = /#{DEC_IP_ADDR}|#{HEX_IP_ADDR}/ + + # IPv6, from Resolv::IPv6, without the \A..\z anchors + HEX_16BIT = /#{HEX_DIGIT}{1,4}/ + IP6_8Hex = /(?:#{HEX_16BIT}:){7}#{HEX_16BIT}/ + IP6_CompressedHex = /((?:#{HEX_16BIT}(?::#{HEX_16BIT})*)?)::((?:#{HEX_16BIT}(?::#{HEX_16BIT})*)?)/ + IP6_6Hex4Dec = /((?:#{HEX_16BIT}:){6,6})#{DEC_IP_ADDR}/ + IP6_CompressedHex4Dec = /((?:#{HEX_16BIT}(?::#{HEX_16BIT})*)?)::((?:#{HEX_16BIT}:)*)#{DEC_IP_ADDR}/ + IP6_ADDR = /(?:#{IP6_8Hex})|(?:#{IP6_CompressedHex})|(?:#{IP6_6Hex4Dec})|(?:#{IP6_CompressedHex4Dec})/ + + # We start with some IRC related regular expressions, used to match + # Irc::User nicks and users and Irc::Channel names + # + # For each of them we define two versions of the regular expression: + # * a generic one, which should match for any server but may turn out to + # match more than a specific server would accept + # * an RFC-compliant matcher + # + module Irc + + # Channel-name-matching regexps + CHAN_FIRST = /[#&+]/ + CHAN_SAFE = /![A-Z0-9]{5}/ + CHAN_ANY = /[^\x00\x07\x0A\x0D ,:]/ + GEN_CHAN = /(?:#{CHAN_FIRST}|#{CHAN_SAFE})#{CHAN_ANY}+/ + RFC_CHAN = /#{CHAN_FIRST}#{CHAN_ANY}{1,49}|#{CHAN_SAFE}#{CHAN_ANY}{1,44}/ + + # Nick-matching regexps + SPECIAL_CHAR = /[\[-\`\{-\}]/ + NICK_FIRST = /#{SPECIAL_CHAR}|[[:alpha:]]/ + NICK_ANY = /#{SPECIAL_CHAR}|[[:alnum:]]|-/ + GEN_NICK = /#{NICK_FIRST}#{NICK_ANY}+/ + RFC_NICK = /#{NICK_FIRST}#{NICK_ANY}{0,8}/ + + USER_CHAR = /[^\x00\x0a\x0d @]/ + GEN_USER = /#{USER_CHAR}+/ + + # Host-matching regexps + HOSTNAME_COMPONENT = /[[:alnum:]](?:[[:alnum:]]|-)*[[:alnum:]]*/ + HOSTNAME = /#{HOSTNAME_COMPONENT}(?:\.#{HOSTNAME_COMPONENT})*/ + HOSTADDR = /#{IP_ADDR}|#{IP6_ADDR}/ + + GEN_HOST = /#{HOSTNAME}|#{HOSTADDR}/ + + # # FreeNode network replaces the host of affiliated users with + # # 'virtual hosts' + # # FIXME we need the true syntax to match it properly ... + # PDPC_HOST_PART = /[0-9A-Za-z.-]+/ + # PDPC_HOST = /#{PDPC_HOST_PART}(?:\/#{PDPC_HOST_PART})+/ + + # # NOTE: the final optional and non-greedy dot is needed because some + # # servers (e.g. FreeNode) send the hostname of the services as "services." + # # which is not RFC compliant, but sadly done. + # GEN_HOST_EXT = /#{PDPC_HOST}|#{GEN_HOST}\.??/ + + # Sadly, different networks have different, RFC-breaking ways of cloaking + # the actualy host address: see above for an example to handle FreeNode. + # Another example would be Azzurra, wich also inserts a "=" in the + # cloacked host. So let's just not care about this and go with the simplest + # thing: + GEN_HOST_EXT = /\S+/ + + # User-matching Regexp + GEN_USER_ID = /(#{GEN_NICK})(?:(?:!(#{GEN_USER}))?@(#{GEN_HOST_EXT}))?/ + + # Things such has the BIP proxy send invalid nicks in a complete netmask, + # so we want to match this, rather: this matches either a compliant nick + # or a a string with a very generic nick, a very generic hostname after an + # @ sign, and an optional user after a ! + BANG_AT = /#{GEN_NICK}|\S+?(?:!\S+?)?@\S+?/ + + # # For Netmask, we want to allow wildcards * and ? in the nick + # # (they are already allowed in the user and host part + # GEN_NICK_MASK = /(?:#{NICK_FIRST}|[?*])?(?:#{NICK_ANY}|[?*])+/ + + # # Netmask-matching Regexp + # GEN_MASK = /(#{GEN_NICK_MASK})(?:(?:!(#{GEN_USER}))?@(#{GEN_HOST_EXT}))?/ + + end + +end + + +module Irc + + + # A Netmask identifies each user by collecting its nick, username and + # hostname in the form nick!user@host + # + # Netmasks can also contain glob patterns in any of their components; in + # this form they are used to refer to more than a user or to a user + # appearing under different forms. + # + # Example: + # * *!*@* refers to everybody + # * *!someuser@somehost refers to user +someuser+ on host +somehost+ + # regardless of the nick used. + # + class Netmask + + # Netmasks have an associated casemap unless they are bound to a server + # + include ServerOrCasemap + + attr_reader :nick, :user, :host + alias :ident :user + + # Create a new Netmask from string _str_, which must be in the form + # _nick_!_user_@_host_ + # + # It is possible to specify a server or a casemap in the optional Hash: + # these are used to associate the Netmask with the given server and to set + # its casemap: if a server is specified and a casemap is not, the server's + # casemap is used. If both a server and a casemap are specified, the + # casemap must match the server's casemap or an exception will be raised. + # + # Empty +nick+, +user+ or +host+ are converted to the generic glob pattern + # + def initialize(str="", opts={}) + # First of all, check for server/casemap option + # + debug 'new netmask "%s" casemap=%s server=%s server#casemap=%s' % [ + str, + (opts[:casemap].class.to_s rescue 'null'), + (opts[:server].hostname.to_s rescue 'null'), + (opts[:server].casemap.class.to_s rescue 'null') + ] + init_server_or_casemap(opts) + + # Now we can see if the given string _str_ is an actual Netmask + if str.respond_to?(:to_str) + case str.to_str + # We match a pretty generic string, to work around non-compliant + # servers + when /^(?:(\S+?)(?:(?:!(\S+?))?@(\S+))?)?$/ + # We do assignment using our internal methods + self.nick = $1 + self.user = $2 + self.host = $3 + else + raise ArgumentError, "#{str.to_str.inspect} does not represent a valid #{self.class}" + end + else + raise TypeError, "#{str} cannot be converted to a #{self.class}" + end + end + + # A Netmask is easily converted to a String for the usual representation. + # We skip the user or host parts if they are "*", unless we've been asked + # for the full form + # + def to_s + ret = nick.dup + ret << "!" << user unless user == "*" + ret << "@" << host unless host == "*" + return ret + end + + def fullform + "#{nick}!#{user}@#{host}" + end + + alias :to_str :fullform + + # This method downcases the fullform of the netmask. While this may not be + # significantly different from the #downcase() method provided by the + # ServerOrCasemap mixin, it's significantly different for Netmask + # subclasses such as User whose simple downcasing uses the nick only. + # + def full_irc_downcase(cmap=casemap) + self.fullform.irc_downcase(cmap) + end + + # full_downcase() will return the fullform downcased according to the + # User's own casemap + # + def full_downcase + self.full_irc_downcase + end + + # This method returns a new Netmask which is the fully downcased version + # of the receiver + def downcased + return self.full_downcase.to_irc_netmask(server_and_casemap) + end + + # Converts the receiver into a Netmask with the given (optional) + # server/casemap association. We return self unless a conversion + # is needed (different casemap/server) + # + # Subclasses of Netmask will return a new Netmask, using full_downcase + # + def to_irc_netmask(opts={}) + if self.class == Netmask + return self if fits_with_server_and_casemap?(opts) + end + return self.full_downcase.to_irc_netmask(server_and_casemap.merge(opts)) + end + + # Converts the receiver into a User with the given (optional) + # server/casemap association. We return self unless a conversion + # is needed (different casemap/server) + # + def to_irc_user(opts={}) + self.fullform.to_irc_user(server_and_casemap.merge(opts)) + end + + # Inspection of a Netmask reveals the server it's bound to (if there is + # one), its casemap and the nick, user and host part + # + def inspect + str = self.__to_s__[0..-2] + str << " @server=#{@server}" if defined?(@server) and @server + str << " @nick=#{@nick.inspect} @user=#{@user.inspect}" + str << " @host=#{@host.inspect} casemap=#{casemap.inspect}" + str << ">" + end + + # Equality: two Netmasks are equal if they downcase to the same thing + # + # TODO we may want it to try other.to_irc_netmask + # + def ==(other) + return false unless other.kind_of?(self.class) + self.downcase == other.downcase + end + + # This method changes the nick of the Netmask, defaulting to the generic + # glob pattern if the result is the null string. + # + def nick=(newnick) + @nick = newnick.to_s + @nick = "*" if @nick.empty? + end + + # This method changes the user of the Netmask, defaulting to the generic + # glob pattern if the result is the null string. + # + def user=(newuser) + @user = newuser.to_s + @user = "*" if @user.empty? + end + alias :ident= :user= + + # This method changes the hostname of the Netmask, defaulting to the generic + # glob pattern if the result is the null string. + # + def host=(newhost) + @host = newhost.to_s + @host = "*" if @host.empty? + end + + # We can replace everything at once with data from another Netmask + # + def replace(other) + case other + when Netmask + nick = other.nick + user = other.user + host = other.host + @server = other.server + @casemap = other.casemap unless @server + else + replace(other.to_irc_netmask(server_and_casemap)) + end + end + + # This method checks if a Netmask is definite or not, by seeing if + # any of its components are defined by globs + # + def has_irc_glob? + return @nick.has_irc_glob? || @user.has_irc_glob? || @host.has_irc_glob? + end + + def generalize + u = user.dup + unless u.has_irc_glob? + u.sub!(/^[in]=/, '=') or u.sub!(/^\W(\w+)/, '\1') + u = '*' + u + end + + h = host.dup + unless h.has_irc_glob? + if h.include? '/' + h.sub!(/x-\w+$/, 'x-*') + else + h.match(/^[^\.]+\.[^\.]+$/) or + h.sub!(/azzurra[=-][0-9a-f]+/i, '*') or # hello, azzurra, you suck! + h.sub!(/^(\d+\.\d+\.\d+\.)\d+$/, '\1*') or + h.sub!(/^[^\.]+\./, '*.') + end + end + return Netmask.new("*!#{u}@#{h}", server_and_casemap) + end + + # This method is used to match the current Netmask against another one + # + # The method returns true if each component of the receiver matches the + # corresponding component of the argument. By _matching_ here we mean + # that any netmask described by the receiver is also described by the + # argument. + # + # In this sense, matching is rather simple to define in the case when the + # receiver has no globs: it is just necessary to check if the argument + # describes the receiver, which can be done by matching it against the + # argument converted into an IRC Regexp (see String#to_irc_regexp). + # + # The situation is also easy when the receiver has globs and the argument + # doesn't, since in this case the result is false. + # + # The more complex case in which both the receiver and the argument have + # globs is not handled yet. + # + def matches?(arg) + cmp = arg.to_irc_netmask(:casemap => casemap) + debug "Matching #{self.fullform} against #{arg.inspect} (#{cmp.fullform})" + [:nick, :user, :host].each { |component| + us = self.send(component).irc_downcase(casemap) + them = cmp.send(component).irc_downcase(casemap) + if us.has_irc_glob? && them.has_irc_glob? + next if us == them + warning NotImplementedError + return false + end + return false if us.has_irc_glob? && !them.has_irc_glob? + return false unless us =~ them.to_irc_regexp + } + return true + end + + # Case equality. Checks if arg matches self + # + def ===(arg) + arg.to_irc_netmask(:casemap => casemap).matches?(self) + end + + # Sorting is done via the fullform + # + def <=>(arg) + case arg + when Netmask + self.fullform.irc_downcase(casemap) <=> arg.fullform.irc_downcase(casemap) + else + self.downcase <=> arg.downcase + end + end + + end + + + # A NetmaskList is an ArrayOf Netmasks + # + class NetmaskList < ArrayOf + + # Create a new NetmaskList, optionally filling it with the elements from + # the Array argument fed to it. + # + def initialize(ar=[]) + super(Netmask, ar) + end + + # We enhance the [] method by allowing it to pick an element that matches + # a given Netmask, a String or a Regexp + # TODO take into consideration the opportunity to use select() instead of + # find(), and/or a way to let the user choose which one to take (second + # argument?) + # + def [](*args) + if args.length == 1 + case args[0] + when Netmask + self.find { |mask| + mask.matches?(args[0]) + } + when String + self.find { |mask| + mask.matches?(args[0].to_irc_netmask(:casemap => mask.casemap)) + } + when Regexp + self.find { |mask| + mask.fullform =~ args[0] + } + else + super(*args) + end + else + super(*args) + end + end + + end + +end + + +class String + + # We keep extending String, this time adding a method that converts a + # String into an Irc::Netmask object + # + def to_irc_netmask(opts={}) + Irc::Netmask.new(self, opts) + end + +end + + +module Irc + + + # An IRC User is identified by his/her Netmask (which must not have globs). + # In fact, User is just a subclass of Netmask. + # + # Ideally, the user and host information of an IRC User should never + # change, and it shouldn't contain glob patterns. However, IRC is somewhat + # idiosincratic and it may be possible to know the nick of a User much before + # its user and host are known. Moreover, some networks (namely Freenode) may + # change the hostname of a User when (s)he identifies with Nickserv. + # + # As a consequence, we must allow changes to a User host and user attributes. + # We impose a restriction, though: they may not contain glob patterns, except + # for the special case of an unknown user/host which is represented by a *. + # + # It is possible to create a totally unknown User (e.g. for initializations) + # by setting the nick to * too. + # + # TODO list: + # * see if it's worth to add the other USER data + # * see if it's worth to add NICKSERV status + # + class User < Netmask + alias :to_s :nick + + attr_accessor :real_name, :idle_since, :signon + + # Create a new IRC User from a given Netmask (or anything that can be converted + # into a Netmask) provided that the given Netmask does not have globs. + # + def initialize(str="", opts={}) + super + raise ArgumentError, "#{str.inspect} must not have globs (unescaped * or ?)" if nick.has_irc_glob? && nick != "*" + raise ArgumentError, "#{str.inspect} must not have globs (unescaped * or ?)" if user.has_irc_glob? && user != "*" + raise ArgumentError, "#{str.inspect} must not have globs (unescaped * or ?)" if host.has_irc_glob? && host != "*" + @away = false + @real_name = String.new + @idle_since = nil + @signon = nil + end + + # The nick of a User may be changed freely, but it must not contain glob patterns. + # + def nick=(newnick) + raise "Can't change the nick to #{newnick}" if defined?(@nick) and newnick.has_irc_glob? + super + end + + # We have to allow changing the user of an Irc User due to some networks + # (e.g. Freenode) changing hostmasks on the fly. We still check if the new + # user data has glob patterns though. + # + def user=(newuser) + raise "Can't change the username to #{newuser}" if defined?(@user) and newuser.has_irc_glob? + super + end + + # We have to allow changing the host of an Irc User due to some networks + # (e.g. Freenode) changing hostmasks on the fly. We still check if the new + # host data has glob patterns though. + # + def host=(newhost) + raise "Can't change the hostname to #{newhost}" if defined?(@host) and newhost.has_irc_glob? + super + end + + # Checks if a User is well-known or not by looking at the hostname and user + # + def known? + return nick != "*" && user != "*" && host != "*" + end + + # Is the user away? + # + def away? + return @away + end + + # Set the away status of the user. Use away=(nil) or away=(false) + # to unset away + # + def away=(msg="") + if msg + @away = msg + else + @away = false + end + end + + # Since to_irc_user runs the same checks on server and channel as + # to_irc_netmask, we just try that and return self if it works. + # + # Subclasses of User will return self if possible. + # + def to_irc_user(opts={}) + return self if fits_with_server_and_casemap?(opts) + return self.full_downcase.to_irc_user(opts) + end + + # We can replace everything at once with data from another User + # + def replace(other) + case other + when User + self.nick = other.nick + self.user = other.user + self.host = other.host + @server = other.server + @casemap = other.casemap unless @server + @away = other.away? + else + self.replace(other.to_irc_user(server_and_casemap)) + end + end + + def modes_on(channel) + case channel + when Channel + channel.modes_of(self) + else + return @server.channel(channel).modes_of(self) if @server + raise "Can't resolve channel #{channel}" + end + end + + def is_op?(channel) + case channel + when Channel + channel.has_op?(self) + else + return @server.channel(channel).has_op?(self) if @server + raise "Can't resolve channel #{channel}" + end + end + + def is_voice?(channel) + case channel + when Channel + channel.has_voice?(self) + else + return @server.channel(channel).has_voice?(self) if @server + raise "Can't resolve channel #{channel}" + end + end + + def channels + if @server + @server.channels.select { |ch| ch.has_user?(self) } + else + Array.new + end + end + end + + + # A UserList is an ArrayOf Users + # We derive it from NetmaskList, which allows us to inherit any special + # NetmaskList method + # + class UserList < NetmaskList + + # Create a new UserList, optionally filling it with the elements from + # the Array argument fed to it. + # + def initialize(ar=[]) + super(ar) + @element_class = User + end + + # Convenience method: convert the UserList to a list of nicks. The indices + # are preserved + # + def nicks + self.map { |user| user.nick } + end + + end + +end + +class String + + # We keep extending String, this time adding a method that converts a + # String into an Irc::User object + # + def to_irc_user(opts={}) + Irc::User.new(self, opts) + end + +end + +module Irc + + # An IRC Channel is identified by its name, and it has a set of properties: + # * a Channel::Topic + # * a UserList + # * a set of Channel::Modes + # + # The Channel::Topic and Channel::Mode classes are defined within the + # Channel namespace because they only make sense there + # + class Channel + + + # Mode on a Channel + # + class Mode + attr_reader :channel + def initialize(ch) + @channel = ch + end + + end + + # Hash of modes. Subclass of Hash that defines any? and all? + # to check if boolean modes (Type D) are set + class ModeHash < Hash + def any?(*ar) + !!ar.find { |m| s = m.to_sym ; self[s] && self[s].set? } + end + def all?(*ar) + !ar.find { |m| s = m.to_sym ; !(self[s] && self[s].set?) } + end + end + + # Channel modes of type A manipulate lists + # + # Example: b (banlist) + # + class ModeTypeA < Mode + attr_reader :list + def initialize(ch) + super + @list = NetmaskList.new + end + + def set(val) + nm = @channel.server.new_netmask(val) + @list << nm unless @list.include?(nm) + end + + def reset(val) + nm = @channel.server.new_netmask(val) + @list.delete(nm) + end + + end + + + # Channel modes of type B need an argument + # + # Example: k (key) + # + class ModeTypeB < Mode + def initialize(ch) + super + @arg = nil + end + + def status + @arg + end + alias :value :status + + def set(val) + @arg = val + end + + def reset(val) + @arg = nil if @arg == val + end + + end + + + # Channel modes that change the User prefixes are like + # Channel modes of type B, except that they manipulate + # lists of Users, so they are somewhat similar to channel + # modes of type A + # + class UserMode < ModeTypeB + attr_reader :list + alias :users :list + def initialize(ch) + super + @list = UserList.new + end + + def set(val) + u = @channel.server.user(val) + @list << u unless @list.include?(u) + end + + def reset(val) + u = @channel.server.user(val) + @list.delete(u) + end + + end + + + # Channel modes of type C need an argument when set, + # but not when they get reset + # + # Example: l (limit) + # + class ModeTypeC < Mode + def initialize(ch) + super + @arg = nil + end + + def status + @arg + end + alias :value :status + + def set(val) + @arg = val + end + + def reset + @arg = nil + end + + end + + + # Channel modes of type D are basically booleans + # + # Example: m (moderate) + # + class ModeTypeD < Mode + def initialize(ch) + super + @set = false + end + + def set? + return @set + end + + def set + @set = true + end + + def reset + @set = false + end + + end + + + # A Topic represents the topic of a channel. It consists of + # the topic itself, who set it and when + # + class Topic + attr_accessor :text, :set_by, :set_on + alias :to_s :text + + # Create a new Topic setting the text, the creator and + # the creation time + # + def initialize(text="", set_by="", set_on=Time.new) + @text = text + @set_by = set_by.to_irc_netmask + @set_on = set_on + end + + # Replace a Topic with another one + # + def replace(topic) + raise TypeError, "#{topic.inspect} is not of class #{self.class}" unless topic.kind_of?(self.class) + @text = topic.text.dup + @set_by = topic.set_by.dup + @set_on = topic.set_on.dup + end + + # Returns self + # + def to_irc_channel_topic + self + end + + end + + end + +end + + +class String + + # Returns an Irc::Channel::Topic with self as text + # + def to_irc_channel_topic + Irc::Channel::Topic.new(self) + end + +end + + +module Irc + + + # Here we start with the actual Channel class + # + class Channel + + # Return the non-prefixed part of a channel name. + # Also works with ## channels found on some networks + # (e.g. FreeNode) + def self.npname(str) + return str.to_s.sub(/^[&#+!]+/,'') + end + + include ServerOrCasemap + attr_reader :name, :topic, :mode, :users + alias :to_s :name + attr_accessor :creation_time, :url + + def inspect + str = self.__to_s__[0..-2] + str << " on server #{server}" if server + str << " @name=#{@name.inspect} @topic=#{@topic.text.inspect}" + str << " @users=[#{user_nicks.sort.join(', ')}]" + str << " (created on #{creation_time})" if creation_time + str << " (URL #{url})" if url + str << ">" + end + + # Returns self + # + def to_irc_channel + self + end + + # TODO Ho + def user_nicks + @users.map { |u| u.downcase } + end + + # Checks if the receiver already has a user with the given _nick_ + # + def has_user?(nick) + @users.index(nick.to_irc_user(server_and_casemap)) + end + + # Returns the user with nick _nick_, if available + # + def get_user(nick) + idx = has_user?(nick) + @users[idx] if idx + end + + # Adds a user to the channel + # + def add_user(user, opts={}) + silent = opts.fetch(:silent, false) + if has_user?(user) + warning "Trying to add user #{user} to channel #{self} again" unless silent + else + @users << user.to_irc_user(server_and_casemap) + end + end + + # Creates a new channel with the given name, optionally setting the topic + # and an initial users list. + # + # No additional info is created here, because the channel flags and userlists + # allowed depend on the server. + # + def initialize(name, topic=nil, users=[], opts={}) + raise ArgumentError, "Channel name cannot be empty" if name.to_s.empty? + warning "Unknown channel prefix #{name[0,1]}" if name !~ /^[&#+!]/ + raise ArgumentError, "Invalid character in #{name.inspect}" if name =~ /[ \x07,]/ + + init_server_or_casemap(opts) + + @name = name + + @topic = topic ? topic.to_irc_channel_topic : Channel::Topic.new + + @users = UserList.new + + users.each { |u| + add_user(u) + } + + # Flags + @mode = ModeHash.new + + # creation time, only on some networks + @creation_time = nil + + # URL, only on some networks + @url = nil + end + + # Removes a user from the channel + # + def delete_user(user) + @mode.each { |sym, mode| + mode.reset(user) if mode.kind_of?(UserMode) + } + @users.delete(user) + end + + # The channel prefix + # + def prefix + name[0,1] + end + + # A channel is local to a server if it has the '&' prefix + # + def local? + name[0,1] == '&' + end + + # A channel is modeless if it has the '+' prefix + # + def modeless? + name[0,1] == '+' + end + + # A channel is safe if it has the '!' prefix + # + def safe? + name[0,1] == '!' + end + + # A channel is normal if it has the '#' prefix + # + def normal? + name[0,1] == '#' + end + + # Create a new mode + # + def create_mode(sym, kl) + @mode[sym.to_sym] = kl.new(self) + end + + def modes_of(user) + l = [] + @mode.map { |s, m| + l << s if (m.class <= UserMode and m.list[user]) + } + l + end + + def has_op?(user) + @mode.has_key?(:o) and @mode[:o].list[user] + end + + def has_voice?(user) + @mode.has_key?(:v) and @mode[:v].list[user] + end + end + + + # A ChannelList is an ArrayOf Channels + # + class ChannelList < ArrayOf + + # Create a new ChannelList, optionally filling it with the elements from + # the Array argument fed to it. + # + def initialize(ar=[]) + super(Channel, ar) + end + + # Convenience method: convert the ChannelList to a list of channel names. + # The indices are preserved + # + def names + self.map { |chan| chan.name } + end + + end + +end + + +class String + + # We keep extending String, this time adding a method that converts a + # String into an Irc::Channel object + # + def to_irc_channel(opts={}) + Irc::Channel.new(self, opts) + end + +end + + +module Irc + + + # An IRC Server represents the Server the client is connected to. + # + class Server + + attr_reader :hostname, :version, :usermodes, :chanmodes + attr_reader :supports, :capabilities + + attr_reader :channels, :users + + # TODO Ho + def channel_names + @channels.map { |ch| ch.downcase } + end + + # TODO Ho + def user_nicks + @users.map { |u| u.downcase } + end + + def inspect + chans, users = [@channels, @users].map {|d| + d.sort { |a, b| + a.downcase <=> b.downcase + }.map { |x| + x.inspect + } + } + + str = self.__to_s__[0..-2] + str << " @hostname=#{hostname}" + str << " @channels=#{chans}" + str << " @users=#{users}" + str << ">" + end + + def to_s + hostname.nil? ? "" : hostname + end + + # Create a new Server, with all instance variables reset to nil (for + # scalar variables), empty channel and user lists and @supports + # initialized to the default values for all known supported features. + # + def initialize + @hostname = @version = @usermodes = @chanmodes = nil + + @channels = ChannelList.new + + @users = UserList.new + + reset_capabilities + end + + # Resets the server capabilities + # + def reset_capabilities + @supports = { + :casemapping => 'rfc1459'.to_irc_casemap, + :chanlimit => {}, + :chanmodes => { + :typea => nil, # Type A: address lists + :typeb => nil, # Type B: needs a parameter + :typec => nil, # Type C: needs a parameter when set + :typed => nil # Type D: must not have a parameter + }, + :channellen => 50, + :chantypes => "#&!+", + :excepts => nil, + :idchan => {}, + :invex => nil, + :kicklen => nil, + :maxlist => {}, + :modes => 3, + :network => nil, + :nicklen => 9, + :prefix => { + :modes => [:o, :v], + :prefixes => [:"@", :+] + }, + :safelist => nil, + :statusmsg => nil, + :std => nil, + :targmax => {}, + :topiclen => nil + } + @capabilities = {} + end + + # Convert a mode (o, v, h, ...) to the corresponding + # prefix (@, +, %, ...). See also mode_for_prefix + def prefix_for_mode(mode) + return @supports[:prefix][:prefixes][ + @supports[:prefix][:modes].index(mode.to_sym) + ] + end + + # Convert a prefix (@, +, %, ...) to the corresponding + # mode (o, v, h, ...). See also prefix_for_mode + def mode_for_prefix(pfx) + return @supports[:prefix][:modes][ + @supports[:prefix][:prefixes].index(pfx.to_sym) + ] + end + + # Resets the Channel and User list + # + def reset_lists + @users.reverse_each { |u| + delete_user(u) + } + @channels.reverse_each { |u| + delete_channel(u) + } + end + + # Clears the server + # + def clear + reset_lists + reset_capabilities + @hostname = @version = @usermodes = @chanmodes = nil + end + + # This method is used to parse a 004 RPL_MY_INFO line + # + def parse_my_info(line) + ar = line.split(' ') + @hostname = ar[0] + @version = ar[1] + @usermodes = ar[2] + @chanmodes = ar[3] + end + + def noval_warn(key, val, &block) + if val + yield if block_given? + else + warning "No #{key.to_s.upcase} value" + end + end + + def val_warn(key, val, &block) + if val == true or val == false or val.nil? + yield if block_given? + else + warning "No #{key.to_s.upcase} value must be specified, got #{val}" + end + end + private :noval_warn, :val_warn + + # This method is used to parse a 005 RPL_ISUPPORT line + # + # See the RPL_ISUPPORT draft[http://www.irc.org/tech_docs/draft-brocklesby-irc-isupport-03.txt] + # + def parse_isupport(line) + debug "Parsing ISUPPORT #{line.inspect}" + ar = line.split(' ') + reparse = [] + ar.each { |en| + prekey, val = en.split('=', 2) + if prekey =~ /^-(.*)/ + key = $1.downcase.to_sym + val = false + else + key = prekey.downcase.to_sym + end + case key + when :casemapping + noval_warn(key, val) { + if val == 'charset' + reparse << "CASEMAPPING=(charset)" + else + # TODO some servers offer non-standard CASEMAPPINGs in the form + # locale.charset[-options], which indicate an extended set of + # allowed characters (mostly for nicks). This might be supported + # with hooks for the unicode core module + @supports[key] = val.to_irc_casemap + end + } + when :chanlimit, :idchan, :maxlist, :targmax + noval_warn(key, val) { + groups = val.split(',') + groups.each { |g| + k, v = g.split(':') + @supports[key][k] = v.to_i || 0 + if @supports[key][k] == 0 + # If no argument is given for a particular command (e.g. "WHOIS:"), + # that command does not have a limit on the number of targets.) + @supports[key].delete(k) + end + } + } + when :chanmodes + noval_warn(key, val) { + groups = val.split(',') + @supports[key][:typea] = groups[0].scan(/./).map { |x| x.to_sym} + @supports[key][:typeb] = groups[1].scan(/./).map { |x| x.to_sym} + @supports[key][:typec] = groups[2].scan(/./).map { |x| x.to_sym} + @supports[key][:typed] = groups[3].scan(/./).map { |x| x.to_sym} + } + when :channellen, :kicklen, :modes, :topiclen + if val + @supports[key] = val.to_i + else + @supports[key] = nil + end + when :chantypes + @supports[key] = val # can also be nil + when :excepts + val ||= 'e' + @supports[key] = val + when :invex + val ||= 'I' + @supports[key] = val + when :maxchannels + noval_warn(key, val) { + reparse << "CHANLIMIT=(chantypes):#{val} " + } + when :maxtargets + noval_warn(key, val) { + @supports[:targmax]['PRIVMSG'] = val.to_i + @supports[:targmax]['NOTICE'] = val.to_i + } + when :network + noval_warn(key, val) { + @supports[key] = val + } + when :nicklen + noval_warn(key, val) { + @supports[key] = val.to_i + } + when :prefix + if val + val.scan(/\((.*)\)(.*)/) { |m, p| + @supports[key][:modes] = m.scan(/./).map { |x| x.to_sym} + @supports[key][:prefixes] = p.scan(/./).map { |x| x.to_sym} + } + else + @supports[key][:modes] = nil + @supports[key][:prefixes] = nil + end + when :safelist + val_warn(key, val) { + @supports[key] = val.nil? ? true : val + } + when :statusmsg + noval_warn(key, val) { + @supports[key] = val.scan(/./) + } + when :std + noval_warn(key, val) { + @supports[key] = val.split(',') + } + else + @supports[key] = val.nil? ? true : val + end + } + unless reparse.empty? + reparse_str = reparse.join(" ") + reparse_str.gsub!("(chantypes)",@supports[:chantypes]) + reparse_str.gsub!("(charset)",@supports[:charset] || 'rfc1459') + parse_isupport(reparse_str) + end + end + + # Returns the casemap of the server. + # + def casemap + @supports[:casemapping] + end + + # Returns User or Channel depending on what _name_ can be + # a name of + # + def user_or_channel?(name) + if supports[:chantypes].include?(name[0]) + return Channel + else + return User + end + end + + # Returns the actual User or Channel object matching _name_ + # + def user_or_channel(name) + if supports[:chantypes].include?(name[0]) + return channel(name) + else + return user(name) + end + end + + # Checks if the receiver already has a channel with the given _name_ + # + def has_channel?(name) + return false if name.nil_or_empty? + channel_names.index(name.irc_downcase(casemap)) + end + alias :has_chan? :has_channel? + + # Returns the channel with name _name_, if available + # + def get_channel(name) + return nil if name.nil_or_empty? + idx = has_channel?(name) + channels[idx] if idx + end + alias :get_chan :get_channel + + # Create a new Channel object bound to the receiver and add it to the + # list of Channels on the receiver, unless the channel was + # present already. In this case, the default action is to raise an + # exception, unless _fails_ is set to false. An exception can also be + # raised if _str_ is nil or empty, again only if _fails_ is set to true; + # otherwise, the method just returns nil + # + def new_channel(name, topic=nil, users=[], fails=true) + if name.nil_or_empty? + raise "Tried to look for empty or nil channel name #{name.inspect}" if fails + return nil + end + ex = get_chan(name) + if ex + raise "Channel #{name} already exists on server #{self}" if fails + return ex + else + + prefix = name[0,1] + + # Give a warning if the new Channel goes over some server limits. + # + # FIXME might need to raise an exception + # + warning "#{self} doesn't support channel prefix #{prefix}" unless @supports[:chantypes].include?(prefix) + warning "#{self} doesn't support channel names this long (#{name.length} > #{@supports[:channellen]})" unless name.length <= @supports[:channellen] + + # Next, we check if we hit the limit for channels of type +prefix+ + # if the server supports +chanlimit+ + # + @supports[:chanlimit].keys.each { |k| + next unless k.include?(prefix) + count = 0 + channel_names.each { |n| + count += 1 if k.include?(n[0]) + } + # raise IndexError, "Already joined #{count} channels with prefix #{k}" if count == @supports[:chanlimit][k] + warning "Already joined #{count}/#{@supports[:chanlimit][k]} channels with prefix #{k}, we may be going over server limits" if count >= @supports[:chanlimit][k] + } + + # So far, everything is fine. Now create the actual Channel + # + chan = Channel.new(name, topic, users, :server => self) + + # We wade through +prefix+ and +chanmodes+ to create appropriate + # lists and flags for this channel + + @supports[:prefix][:modes].each { |mode| + chan.create_mode(mode, Channel::UserMode) + } if @supports[:prefix][:modes] + + @supports[:chanmodes].each { |k, val| + if val + case k + when :typea + val.each { |mode| + chan.create_mode(mode, Channel::ModeTypeA) + } + when :typeb + val.each { |mode| + chan.create_mode(mode, Channel::ModeTypeB) + } + when :typec + val.each { |mode| + chan.create_mode(mode, Channel::ModeTypeC) + } + when :typed + val.each { |mode| + chan.create_mode(mode, Channel::ModeTypeD) + } + end + end + } + + @channels << chan + # debug "Created channel #{chan.inspect}" + return chan + end + end + + # Returns the Channel with the given _name_ on the server, + # creating it if necessary. This is a short form for + # new_channel(_str_, nil, [], +false+) + # + def channel(str) + new_channel(str,nil,[],false) + end + + # Remove Channel _name_ from the list of Channels + # + def delete_channel(name) + idx = has_channel?(name) + raise "Tried to remove unmanaged channel #{name}" unless idx + @channels.delete_at(idx) + end + + # Checks if the receiver already has a user with the given _nick_ + # + def has_user?(nick) + return false if nick.nil_or_empty? + user_nicks.index(nick.irc_downcase(casemap)) + end + + # Returns the user with nick _nick_, if available + # + def get_user(nick) + idx = has_user?(nick) + @users[idx] if idx + end + + # Create a new User object bound to the receiver and add it to the list + # of Users on the receiver, unless the User was present + # already. In this case, the default action is to raise an exception, + # unless _fails_ is set to false. An exception can also be raised + # if _str_ is nil or empty, again only if _fails_ is set to true; + # otherwise, the method just returns nil + # + def new_user(str, fails=true) + if str.nil_or_empty? + raise "Tried to look for empty or nil user name #{str.inspect}" if fails + return nil + end + tmp = str.to_irc_user(:server => self) + old = get_user(tmp.nick) + # debug "Tmp: #{tmp.inspect}" + # debug "Old: #{old.inspect}" + if old + # debug "User already existed as #{old.inspect}" + if tmp.known? + if old.known? + # debug "Both were known" + # Do not raise an error: things like Freenode change the hostname after identification + warning "User #{tmp.nick} has inconsistent Netmasks! #{self} knows #{old.inspect} but access was tried with #{tmp.inspect}" if old != tmp + raise "User #{tmp} already exists on server #{self}" if fails + end + if old.fullform.downcase != tmp.fullform.downcase + old.replace(tmp) + # debug "Known user now #{old.inspect}" + end + end + return old + else + warning "#{self} doesn't support nicknames this long (#{tmp.nick.length} > #{@supports[:nicklen]})" unless tmp.nick.length <= @supports[:nicklen] + @users << tmp + return @users.last + end + end + + # Returns the User with the given Netmask on the server, + # creating it if necessary. This is a short form for + # new_user(_str_, +false+) + # + def user(str) + new_user(str, false) + end + + # Deletes User _user_ from Channel _channel_ + # + def delete_user_from_channel(user, channel) + channel.delete_user(user) + end + + # Remove User _someuser_ from the list of Users. + # _someuser_ must be specified with the full Netmask. + # + def delete_user(someuser) + idx = has_user?(someuser) + raise "Tried to remove unmanaged user #{user}" unless idx + have = self.user(someuser) + @channels.each { |ch| + delete_user_from_channel(have, ch) + } + @users.delete_at(idx) + end + + # Create a new Netmask object with the appropriate casemap + # + def new_netmask(str) + str.to_irc_netmask(:server => self) + end + + # Finds all Users on server whose Netmask matches _mask_ + # + def find_users(mask) + nm = new_netmask(mask) + @users.inject(UserList.new) { + |list, user| + if user.user == "*" or user.host == "*" + list << user if user.nick.irc_downcase(casemap) =~ nm.nick.irc_downcase(casemap).to_irc_regexp + else + list << user if user.matches?(nm) + end + list + } + end + + end + +end +