#-- 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
# * 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)
# Copyright:: Copyright (c) 2006 Giuseppe Bilotta
# License:: GPLv2
require 'singleton'
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
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.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
# 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
# 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.inspect} != #{other.inspect})" 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
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
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
# 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 = /[\x5b-\x60\x7b-\x7d]/
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
# 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
#
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
# 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(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.class}:#{'0x%x' % self.object_id}:"
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
# 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)
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
warn 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 Netmask
s
#
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
# 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
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
end
# A UserList is an ArrayOf User
s
# 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
# 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
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=[#{user_nicks.sort.join(', ')}]"
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)
warn "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?
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|
add_user(u)
}
# 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
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 Channel
s
#
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
alias :to_s :hostname
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.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'.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
# 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
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
noval_warn(key, val) {
@supports[key] = val.to_irc_casemap
}
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
warn "Deleting #{key} limit of 0 for #{k}"
@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
}
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)
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 Channel
s 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].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]
warn "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 Channel
s
#
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 User
s 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
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 User
s.
# _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 User
s 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