diff options
-rw-r--r-- | data/rbot/plugins/bans.rb | 1008 | ||||
-rw-r--r-- | data/rbot/plugins/deepthoughts.rb | 178 | ||||
-rw-r--r-- | data/rbot/plugins/dict.rb | 348 | ||||
-rw-r--r-- | data/rbot/plugins/games/azgame.rb | 1132 | ||||
-rw-r--r-- | data/rbot/plugins/hl2.rb | 118 | ||||
-rw-r--r-- | data/rbot/plugins/rss.rb | 2214 | ||||
-rw-r--r-- | data/rbot/plugins/salut.rb | 452 | ||||
-rw-r--r-- | lib/rbot/botuser.rb | 1848 | ||||
-rw-r--r-- | lib/rbot/core/auth.rb | 1936 | ||||
-rw-r--r-- | lib/rbot/core/basics.rb | 376 | ||||
-rw-r--r-- | lib/rbot/core/config.rb | 650 | ||||
-rw-r--r-- | lib/rbot/irc.rb | 3916 | ||||
-rw-r--r-- | lib/rbot/plugins/opmeh.rb | 38 |
13 files changed, 7107 insertions, 7107 deletions
diff --git a/data/rbot/plugins/bans.rb b/data/rbot/plugins/bans.rb index ac5262b7..3818ce22 100644 --- a/data/rbot/plugins/bans.rb +++ b/data/rbot/plugins/bans.rb @@ -1,504 +1,504 @@ -#-- vim:sw=2:et
-#++
-#
-# :title: Bans Plugin v3 for rbot 0.9.11 and later
-#
-# Author:: Marco Gulino <marco@kmobiletools.org>
-# Author:: kamu <mr.kamu@gmail.com>
-# Author:: Giuseppe Bilotta <giuseppe.bilotta@gmail.com>
-#
-# Copyright:: (C) 2006 Marco Gulino
-# Copyright:: (C) 2007 kamu, Giuseppe Bilotta
-#
-# License:: GPL V2.
-#
-# Managing kick and bans, automatically removing bans after timeouts, quiet
-# bans, and kickban/quietban based on regexp
-#
-# v1 -> v2 (kamu's version, never released)
-# * reworked
-# * autoactions triggered on join
-# * action on join or badword can be anything: kick, ban, kickban, quiet
-#
-# v2 -> v3 (GB)
-# * remove the 'bans' prefix from most of the commands
-# * (un)quiet has been renamed to (un)silence because 'quiet' was used to
-# tell the bot to keep quiet
-# * both (un)quiet and (un)silence are accepted as actions
-# * use the more descriptive 'onjoin' term for autoactions
-# * convert v1's (0.9.10) :bans and :bansmasks to BadWordActions and
-# WhitelistEntries
-# * enhanced list manipulation facilities
-# * fixed regexp usage in requirements for plugin map
-# * add proper auth management
-
-define_structure :OnJoinAction, :host, :action, :channel, :reason
-define_structure :BadWordAction, :regexp, :action, :channel, :timer, :reason
-define_structure :WhitelistEntry, :host, :channel
-
-class BansPlugin < Plugin
-
- IdxRe = /^\d+$/
- TimerRe = /^\d+[smhd]$/
- ChannelRe = /^#+[^\s]+$/
- ChannelAllRe = /^(?:all|#+[^\s]+)$/
- ActionRe = /(?:ban|kick|kickban|silence|quiet)/
-
- def name
- "bans"
- end
-
- def make_badword_rx(txt)
- return /\b(?:#{txt})\b/i
- end
-
- def initialize
- super
-
- # Convert old BadWordActions, which were simpler and labelled :bans
- if @registry.has_key? :bans
- badwords = Array.new
- bans = @registry[:bans]
- @registry[:bans].each { |ar|
- case ar[0]
- when "quietban"
- action = :silence
- when "kickban"
- action = :kickban
- else
- # Shouldn't happen
- warning "Unknown action in old data #{ar.inspect} -- entry ignored"
- next
- end
- bans.delete(ar)
- chan = ar[1].downcase
- regexp = make_badword_rx(ar[2])
- badwords << BadWordAction.new(regexp, action, chan, "0s", "")
- }
- @registry[:badwords] = badwords
- if bans.length > 0
- # Store the ones we couldn't convert
- @registry[:bans] = bans
- else
- @registry.delete(:bans)
- end
- else
- @registry[:badwords] = Array.new unless @registry.has_key? :badwords
- end
-
- # Convert old WhitelistEntries, which were simpler and labelled :bansmasks
- if @registry.has_key? :bans
- wl = Array.new
- @registry[:bansmasks].each { |mask|
- badwords << WhitelistEntry.new(mask, "all")
- }
- @registry[:whitelist] = wl
- @registry.delete(:bansmasks)
- else
- @registry[:whitelist] = Array.new unless @registry.has_key? :whitelist
- end
-
- @registry[:onjoin] = Array.new unless @registry.has_key? :onjoin
- end
-
- def help(plugin, topic="")
- case plugin
- when "ban"
- return "ban <nick/hostmask> [Xs/m/h/d] [#channel]: ban a user from the given channel for the given amount of time. default is forever, on the current channel"
- when "unban"
- return "unban <nick/hostmask> [#channel]: unban a user from the given channel. defaults to the current channel"
- when "kick"
- return "kick <nick> [#channel] [reason ...]: kick a user from the given channel with the given reason. defaults to the current channel, no reason"
- when "kickban"
- return "kickban <nick> [Xs/m/h/d] [#channel] [reason ...]: kicks and bans a user from the given channel for the given amount of time, with the given reason. default is forever, on the current channel, with no reason"
- when "silence"
- return "silence <nick/hostmask> [Xs/m/h/d] [#channel]: silence a user on the given channel for the given time. default is forever, on the current channel. not all servers support silencing users"
- when "unsilence"
- return "unsilence <nick/hostmask> [#channel]: allow the given user to talk on the given channel. defaults to the current channel"
- when "bans"
- case topic
- when "add"
- return "bans add <onjoin|badword|whitelist>: add an automatic action for people that join or say some bad word, or a whitelist entry. further help available"
- when "add onjoin"
- return "bans add onjoin <hostmask> [action] [#channel] [reason ...]: will add an autoaction for any one who joins with hostmask. default action is silence, default channel is all"
- when "add badword"
- return "bans add badword <regexp> [action] [Xs/m/h/d] [#channel|all] [reason ...]: adds a badword regexp, if a user sends a message that matches regexp, the action will be invoked. default action is silence, default channel is all"
- when "add whitelist"
- return "bans add whitelist <hostmask> [#channel|all]: add the given hostmask to the whitelist. no autoaction will be triggered by users on the whitelist"
- when "rm"
- return "bans rm <onjoin|badword|whitelist> <hostmask/regexp> [#channel], or bans rm <onjoin|badword|whitelist> index <num>: removes the specified onjoin or badword rule or whitelist entry."
- when "list"
- return"bans list <onjoin|badword|whitelist>: lists all onjoin or badwords or whitelist entries"
- end
- end
- return "bans <command>: allows a user of the bot to do a range of bans and unbans. commands are: [un]ban, kick[ban], [un]silence, add, rm and list"
- end
-
- def listen(m)
- return unless m.respond_to?(:public?) and m.public?
- @registry[:whitelist].each { |white|
- next unless ['all', m.target.downcase].include?(white.channel)
- return if m.source.matches?(white.host)
- }
-
- @registry[:badwords].each { |badword|
- next unless ['all', m.target.downcase].include?(badword.channel)
- next unless badword.regexp.match(m.message)
-
- do_cmd(badword.action.to_sym, m.source.nick, m.target, badword.timer, badword.reason)
- m.reply "bad word detected! #{badword.action} for #{badword.timer} because: #{badword.reason}"
- return
- }
- end
-
- def join(m)
- @registry[:whitelist].each { |white|
- next unless ['all', m.target.downcase].include?(white.channel)
- return if m.source.matches?(white.host)
- }
-
- @registry[:onjoin].each { |auto|
- next unless ['all', m.target.downcase].include?(auto.channel)
- next unless m.source.matches? auto.host
-
- do_cmd(auto.action.to_sym, m.source.nick, m.target, "0s", auto.reason)
- return
- }
- end
-
- def ban_user(m, params=nil)
- nick, channel = params[:nick], check_channel(m, params[:channel])
- timer = params[:timer]
- do_cmd(:ban, nick, channel, timer)
- end
-
- def unban_user(m, params=nil)
- nick, channel = params[:nick], check_channel(m, params[:channel])
- do_cmd(:unban, nick, channel)
- end
-
- def kick_user(m, params=nil)
- nick, channel = params[:nick], check_channel(m, params[:channel])
- reason = params[:reason].to_s
- do_cmd(:kick, nick, channel, "0s", reason)
- end
-
- def kickban_user(m, params=nil)
- nick, channel, reason = params[:nick], check_channel(m, params[:channel])
- timer, reason = params[:timer], params[:reason].to_s
- do_cmd(:kickban, nick, channel, timer, reason)
- end
-
- def silence_user(m, params=nil)
- nick, channel = params[:nick], check_channel(m, params[:channel])
- timer = params[:timer]
- do_cmd(:silence, nick, channel, timer)
- end
-
- def unsilence_user(m, params=nil)
- nick, channel = params[:nick], check_channel(m, params[:channel])
- do_cmd(:unsilence, nick, channel)
- end
-
- def add_onjoin(m, params=nil)
- begin
- host, channel = m.server.new_netmask(params[:host]), params[:channel].downcase
- action, reason = params[:action], params[:reason].to_s
-
- autos = @registry[:onjoin]
- autos << OnJoinAction.new(host, action, channel, reason.dup)
- @registry[:onjoin] = autos
-
- m.okay
- rescue
- error $!
- m.reply $!
- end
- end
-
- def list_onjoin(m, params=nil)
- m.reply "onjoin rules: #{@registry[:onjoin].length}"
- @registry[:onjoin].each_with_index { |auto, idx|
- m.reply "\##{idx+1}: #{auto.host} | #{auto.action} | #{auto.channel} | '#{auto.reason}'"
- }
- end
-
- def rm_onjoin(m, params=nil)
- autos = @registry[:onjoin]
- count = autos.length
-
- idx = nil
- idx = params[:idx].to_i if params[:idx]
-
- if idx
- if idx > count
- m.reply "No such onjoin \##{idx}"
- return
- end
- autos.delete_at(idx-1)
- else
- begin
- host = m.server.new_netmask(params[:host])
- channel = params[:channel].downcase
-
- autos.each { |rule|
- next unless ['all', rule.channel].include?(channel)
- autos.delete rule if rule.host == host
- }
- rescue
- error $!
- m.reply $!
- end
- end
- @registry[:onjoin] = autos
- if count > autos.length
- m.okay
- else
- m.reply "No matching onjoin rule for #{host} found"
- end
- end
-
- def add_badword(m, params=nil)
- regexp, channel = make_badword_rx(params[:regexp]), params[:channel].downcase.dup
- action, timer, reason = params[:action], params[:timer].dup, params[:reason].to_s
-
- badwords = @registry[:badwords]
- badwords << BadWordAction.new(regexp, action, channel, timer, reason)
- @registry[:badwords] = badwords
-
- m.okay
- end
-
- def list_badword(m, params=nil)
- m.reply "badword rules: #{@registry[:badwords].length}"
-
- @registry[:badwords].each_with_index { |badword, idx|
- m.reply "\##{idx+1}: #{badword.regexp.source} | #{badword.action} | #{badword.channel} | #{badword.timer} | #{badword.reason}"
- }
- end
-
- def rm_badword(m, params=nil)
- badwords = @registry[:badwords]
- count = badwords.length
-
- idx = nil
- idx = params[:idx].to_i if params[:idx]
-
- if idx
- if idx > count
- m.reply "No such badword \##{idx}"
- return
- end
- badwords.delete_at(idx-1)
- else
- channel = params[:channel].downcase
-
- regexp = make_badword_rx(params[:regexp])
- debug "Trying to remove #{regexp.inspect} from #{badwords.inspect}"
-
- badwords.each { |badword|
- next unless ['all', badword.channel].include?(channel)
- debug "Removing #{badword.inspect}" if badword == regexp
- badwords.delete(badword) if badword == regexp
- }
- end
-
- @registry[:badwords] = badwords
- if count > badwords.length
- m.okay
- else
- m.reply "No matching badword #{regexp} found"
- end
- end
-
- def add_whitelist(m, params=nil)
- begin
- host, channel = m.server.new_netmask(params[:host]), params[:channel].downcase
-
- # TODO check if a whitelist entry for this host already exists
- whitelist = @registry[:whitelist]
- whitelist << WhitelistEntry.new(host, channel)
- @registry[:whitelist] = whitelist
-
- m.okay
- rescue
- error $!
- m.reply $!
- end
- end
-
- def list_whitelist(m, params=nil)
- m.reply "whitelist entries: #{@registry[:whitelist].length}"
- @registry[:whitelist].each_with_index { |auto, idx|
- m.reply "\##{idx+1}: #{auto.host} | #{auto.channel}"
- }
- end
-
- def rm_whitelist(m, params=nil)
- wl = @registry[:whitelist]
- count = wl.length
-
- idx = nil
- idx = params[:idx].to_i if params[:idx]
-
- if idx
- if idx > count
- m.reply "No such whitelist entry \##{idx}"
- return
- end
- wl.delete_at(idx-1)
- else
- begin
- host = m.server.new_netmask(params[:host])
- channel = params[:channel].downcase
-
- wl.each { |rule|
- next unless ['all', rule.channel].include?(channel)
- wl.delete rule if rule.host == host
- }
- rescue
- error $!
- m.reply $!
- end
- end
- @registry[:whitelist] = wl
- if count > whitelist.length
- m.okay
- else
- m.reply "No host matching #{host}"
- end
- end
-
- private
- def check_channel(m, strchannel)
- begin
- raise "must specify channel if using privmsg" if m.private? and not strchannel
- channel = m.server.channel(strchannel) || m.target
- raise "I am not in that channel" unless channel.has_user?(@bot.nick)
-
- return channel
- rescue
- error $!
- m.reply $!
- end
- end
-
- def do_cmd(action, nick, channel, timer_in=nil, reason=nil)
- case timer_in
- when nil
- timer = 0
- when /^(\d+)s$/
- timer = $1.to_i
- when /^(\d+)m$/
- timer = $1.to_i * 60
- when /^(\d+)h$/
- timer = $1.to_i * 60 * 60
- when /^(\d+)d$/
- timer = $1.to_i * 60 * 60 * 24
- else
- raise "Wrong time specifications"
- end
-
- case action
- when :ban
- set_mode(channel, "+b", nick)
- @bot.timer.add_once(timer) { set_mode(channel, "-b", nick) } if timer > 0
- when :unban
- set_mode(channel, "-b", nick)
- when :kick
- do_kick(channel, nick, reason)
- when :kickban
- set_mode(channel, "+b", nick)
- @bot.timer.add_once(timer) { set_mode(channel, "-b", nick) } if timer > 0
- do_kick(channel, nick, reason)
- when :silence, :quiet
- set_mode(channel, "+q", nick)
- @bot.timer.add_once(timer) { set_mode(channel, "-q", nick) } if timer > 0
- when :unsilence, :unquiet
- set_mode(channel, "-q", nick)
- end
- end
-
- def set_mode(channel, mode, nick)
- host = channel.has_user?(nick) ? "*!*@" + channel.get_user(nick).host : nick
- @bot.mode(channel, mode, host)
- end
-
- def do_kick(channel, nick, reason="")
- @bot.kick(channel, nick, reason)
- end
-end
-
-plugin = BansPlugin.new
-
-plugin.default_auth( 'act', false )
-plugin.default_auth( 'edit', false )
-plugin.default_auth( 'list', true )
-
-plugin.map 'ban :nick :timer :channel', :action => 'ban_user',
- :requirements => {:timer => BansPlugin::TimerRe, :channel => BansPlugin::ChannelRe},
- :defaults => {:timer => nil, :channel => nil},
- :auth_path => 'act'
-plugin.map 'unban :nick :channel', :action => 'unban_user',
- :requirements => {:channel => BansPlugin::ChannelRe},
- :defaults => {:channel => nil},
- :auth_path => 'act'
-plugin.map 'kick :nick :channel *reason', :action => 'kick_user',
- :requirements => {:channel => BansPlugin::ChannelRe},
- :defaults => {:channel => nil, :reason => 'requested'},
- :auth_path => 'act'
-plugin.map 'kickban :nick :timer :channel *reason', :action => 'kickban_user',
- :requirements => {:timer => BansPlugin::TimerRe, :channel => BansPlugin::ChannelRe},
- :defaults => {:timer => nil, :channel => nil, :reason => 'requested'},
- :auth_path => 'act'
-plugin.map 'silence :nick :timer :channel', :action => 'silence_user',
- :requirements => {:timer => BansPlugin::TimerRe, :channel => BansPlugin::ChannelRe},
- :defaults => {:timer => nil, :channel => nil},
- :auth_path => 'act'
-plugin.map 'unsilence :nick :channel', :action => 'unsilence_user',
- :requirements => {:channel => BansPlugin::ChannelRe},
- :defaults => {:channel => nil},
- :auth_path => 'act'
-
-plugin.map 'bans add onjoin :host :action :channel *reason', :action => 'add_onjoin',
- :requirements => {:action => BansPlugin::ActionRe, :channel => BansPlugin::ChannelAllRe},
- :defaults => {:action => 'kickban', :channel => 'all', :reason => 'netmask not welcome'},
- :auth_path => 'edit::onjoin'
-plugin.map 'bans rm onjoin index :idx', :action => 'rm_onjoin',
- :requirements => {:num => BansPlugin::IdxRe},
- :auth_path => 'edit::onjoin'
-plugin.map 'bans rm onjoin :host :channel', :action => 'rm_onjoin',
- :requirements => {:channel => BansPlugin::ChannelAllRe},
- :defaults => {:channel => 'all'},
- :auth_path => 'edit::onjoin'
-plugin.map 'bans list onjoin[s]', :action => 'list_onjoin',
- :auth_path => 'list::onjoin'
-
-plugin.map 'bans add badword :regexp :action :timer :channel *reason', :action => 'add_badword',
- :requirements => {:action => BansPlugin::ActionRe, :timer => BansPlugin::TimerRe, :channel => BansPlugin::ChannelAllRe},
- :defaults => {:action => 'silence', :timer => "0s", :channel => 'all', :reason => 'bad word'},
- :auth_path => 'edit::badword'
-plugin.map 'bans rm badword index :idx', :action => 'rm_badword',
- :requirements => {:num => BansPlugin::IdxRe},
- :auth_path => 'edit::badword'
-plugin.map 'bans rm badword :regexp :channel', :action => 'rm_badword',
- :requirements => {:channel => BansPlugin::ChannelAllRe},
- :defaults => {:channel => 'all'},
- :auth_path => 'edit::badword'
-plugin.map 'bans list badword[s]', :action => 'list_badword',
- :auth_path => 'list::badword'
-
-plugin.map 'bans add whitelist :host :channel', :action => 'add_whitelist',
- :requirements => {:channel => BansPlugin::ChannelAllRe},
- :defaults => {:channel => 'all'},
- :auth_path => 'edit::whitelist'
-plugin.map 'bans rm whitelist index :idx', :action => 'rm_whitelist',
- :requirements => {:num => BansPlugin::IdxRe},
- :auth_path => 'edit::whitelist'
-plugin.map 'bans rm whitelist :host :channel', :action => 'rm_whitelist',
- :requirements => {:channel => BansPlugin::ChannelAllRe},
- :defaults => {:channel => 'all'},
- :auth_path => 'edit::whitelist'
-plugin.map 'bans list whitelist', :action => 'list_whitelist',
- :auth_path => 'list::whitelist'
-
+#-- vim:sw=2:et +#++ +# +# :title: Bans Plugin v3 for rbot 0.9.11 and later +# +# Author:: Marco Gulino <marco@kmobiletools.org> +# Author:: kamu <mr.kamu@gmail.com> +# Author:: Giuseppe Bilotta <giuseppe.bilotta@gmail.com> +# +# Copyright:: (C) 2006 Marco Gulino +# Copyright:: (C) 2007 kamu, Giuseppe Bilotta +# +# License:: GPL V2. +# +# Managing kick and bans, automatically removing bans after timeouts, quiet +# bans, and kickban/quietban based on regexp +# +# v1 -> v2 (kamu's version, never released) +# * reworked +# * autoactions triggered on join +# * action on join or badword can be anything: kick, ban, kickban, quiet +# +# v2 -> v3 (GB) +# * remove the 'bans' prefix from most of the commands +# * (un)quiet has been renamed to (un)silence because 'quiet' was used to +# tell the bot to keep quiet +# * both (un)quiet and (un)silence are accepted as actions +# * use the more descriptive 'onjoin' term for autoactions +# * convert v1's (0.9.10) :bans and :bansmasks to BadWordActions and +# WhitelistEntries +# * enhanced list manipulation facilities +# * fixed regexp usage in requirements for plugin map +# * add proper auth management + +define_structure :OnJoinAction, :host, :action, :channel, :reason +define_structure :BadWordAction, :regexp, :action, :channel, :timer, :reason +define_structure :WhitelistEntry, :host, :channel + +class BansPlugin < Plugin + + IdxRe = /^\d+$/ + TimerRe = /^\d+[smhd]$/ + ChannelRe = /^#+[^\s]+$/ + ChannelAllRe = /^(?:all|#+[^\s]+)$/ + ActionRe = /(?:ban|kick|kickban|silence|quiet)/ + + def name + "bans" + end + + def make_badword_rx(txt) + return /\b(?:#{txt})\b/i + end + + def initialize + super + + # Convert old BadWordActions, which were simpler and labelled :bans + if @registry.has_key? :bans + badwords = Array.new + bans = @registry[:bans] + @registry[:bans].each { |ar| + case ar[0] + when "quietban" + action = :silence + when "kickban" + action = :kickban + else + # Shouldn't happen + warning "Unknown action in old data #{ar.inspect} -- entry ignored" + next + end + bans.delete(ar) + chan = ar[1].downcase + regexp = make_badword_rx(ar[2]) + badwords << BadWordAction.new(regexp, action, chan, "0s", "") + } + @registry[:badwords] = badwords + if bans.length > 0 + # Store the ones we couldn't convert + @registry[:bans] = bans + else + @registry.delete(:bans) + end + else + @registry[:badwords] = Array.new unless @registry.has_key? :badwords + end + + # Convert old WhitelistEntries, which were simpler and labelled :bansmasks + if @registry.has_key? :bans + wl = Array.new + @registry[:bansmasks].each { |mask| + badwords << WhitelistEntry.new(mask, "all") + } + @registry[:whitelist] = wl + @registry.delete(:bansmasks) + else + @registry[:whitelist] = Array.new unless @registry.has_key? :whitelist + end + + @registry[:onjoin] = Array.new unless @registry.has_key? :onjoin + end + + def help(plugin, topic="") + case plugin + when "ban" + return "ban <nick/hostmask> [Xs/m/h/d] [#channel]: ban a user from the given channel for the given amount of time. default is forever, on the current channel" + when "unban" + return "unban <nick/hostmask> [#channel]: unban a user from the given channel. defaults to the current channel" + when "kick" + return "kick <nick> [#channel] [reason ...]: kick a user from the given channel with the given reason. defaults to the current channel, no reason" + when "kickban" + return "kickban <nick> [Xs/m/h/d] [#channel] [reason ...]: kicks and bans a user from the given channel for the given amount of time, with the given reason. default is forever, on the current channel, with no reason" + when "silence" + return "silence <nick/hostmask> [Xs/m/h/d] [#channel]: silence a user on the given channel for the given time. default is forever, on the current channel. not all servers support silencing users" + when "unsilence" + return "unsilence <nick/hostmask> [#channel]: allow the given user to talk on the given channel. defaults to the current channel" + when "bans" + case topic + when "add" + return "bans add <onjoin|badword|whitelist>: add an automatic action for people that join or say some bad word, or a whitelist entry. further help available" + when "add onjoin" + return "bans add onjoin <hostmask> [action] [#channel] [reason ...]: will add an autoaction for any one who joins with hostmask. default action is silence, default channel is all" + when "add badword" + return "bans add badword <regexp> [action] [Xs/m/h/d] [#channel|all] [reason ...]: adds a badword regexp, if a user sends a message that matches regexp, the action will be invoked. default action is silence, default channel is all" + when "add whitelist" + return "bans add whitelist <hostmask> [#channel|all]: add the given hostmask to the whitelist. no autoaction will be triggered by users on the whitelist" + when "rm" + return "bans rm <onjoin|badword|whitelist> <hostmask/regexp> [#channel], or bans rm <onjoin|badword|whitelist> index <num>: removes the specified onjoin or badword rule or whitelist entry." + when "list" + return"bans list <onjoin|badword|whitelist>: lists all onjoin or badwords or whitelist entries" + end + end + return "bans <command>: allows a user of the bot to do a range of bans and unbans. commands are: [un]ban, kick[ban], [un]silence, add, rm and list" + end + + def listen(m) + return unless m.respond_to?(:public?) and m.public? + @registry[:whitelist].each { |white| + next unless ['all', m.target.downcase].include?(white.channel) + return if m.source.matches?(white.host) + } + + @registry[:badwords].each { |badword| + next unless ['all', m.target.downcase].include?(badword.channel) + next unless badword.regexp.match(m.message) + + do_cmd(badword.action.to_sym, m.source.nick, m.target, badword.timer, badword.reason) + m.reply "bad word detected! #{badword.action} for #{badword.timer} because: #{badword.reason}" + return + } + end + + def join(m) + @registry[:whitelist].each { |white| + next unless ['all', m.target.downcase].include?(white.channel) + return if m.source.matches?(white.host) + } + + @registry[:onjoin].each { |auto| + next unless ['all', m.target.downcase].include?(auto.channel) + next unless m.source.matches? auto.host + + do_cmd(auto.action.to_sym, m.source.nick, m.target, "0s", auto.reason) + return + } + end + + def ban_user(m, params=nil) + nick, channel = params[:nick], check_channel(m, params[:channel]) + timer = params[:timer] + do_cmd(:ban, nick, channel, timer) + end + + def unban_user(m, params=nil) + nick, channel = params[:nick], check_channel(m, params[:channel]) + do_cmd(:unban, nick, channel) + end + + def kick_user(m, params=nil) + nick, channel = params[:nick], check_channel(m, params[:channel]) + reason = params[:reason].to_s + do_cmd(:kick, nick, channel, "0s", reason) + end + + def kickban_user(m, params=nil) + nick, channel, reason = params[:nick], check_channel(m, params[:channel]) + timer, reason = params[:timer], params[:reason].to_s + do_cmd(:kickban, nick, channel, timer, reason) + end + + def silence_user(m, params=nil) + nick, channel = params[:nick], check_channel(m, params[:channel]) + timer = params[:timer] + do_cmd(:silence, nick, channel, timer) + end + + def unsilence_user(m, params=nil) + nick, channel = params[:nick], check_channel(m, params[:channel]) + do_cmd(:unsilence, nick, channel) + end + + def add_onjoin(m, params=nil) + begin + host, channel = m.server.new_netmask(params[:host]), params[:channel].downcase + action, reason = params[:action], params[:reason].to_s + + autos = @registry[:onjoin] + autos << OnJoinAction.new(host, action, channel, reason.dup) + @registry[:onjoin] = autos + + m.okay + rescue + error $! + m.reply $! + end + end + + def list_onjoin(m, params=nil) + m.reply "onjoin rules: #{@registry[:onjoin].length}" + @registry[:onjoin].each_with_index { |auto, idx| + m.reply "\##{idx+1}: #{auto.host} | #{auto.action} | #{auto.channel} | '#{auto.reason}'" + } + end + + def rm_onjoin(m, params=nil) + autos = @registry[:onjoin] + count = autos.length + + idx = nil + idx = params[:idx].to_i if params[:idx] + + if idx + if idx > count + m.reply "No such onjoin \##{idx}" + return + end + autos.delete_at(idx-1) + else + begin + host = m.server.new_netmask(params[:host]) + channel = params[:channel].downcase + + autos.each { |rule| + next unless ['all', rule.channel].include?(channel) + autos.delete rule if rule.host == host + } + rescue + error $! + m.reply $! + end + end + @registry[:onjoin] = autos + if count > autos.length + m.okay + else + m.reply "No matching onjoin rule for #{host} found" + end + end + + def add_badword(m, params=nil) + regexp, channel = make_badword_rx(params[:regexp]), params[:channel].downcase.dup + action, timer, reason = params[:action], params[:timer].dup, params[:reason].to_s + + badwords = @registry[:badwords] + badwords << BadWordAction.new(regexp, action, channel, timer, reason) + @registry[:badwords] = badwords + + m.okay + end + + def list_badword(m, params=nil) + m.reply "badword rules: #{@registry[:badwords].length}" + + @registry[:badwords].each_with_index { |badword, idx| + m.reply "\##{idx+1}: #{badword.regexp.source} | #{badword.action} | #{badword.channel} | #{badword.timer} | #{badword.reason}" + } + end + + def rm_badword(m, params=nil) + badwords = @registry[:badwords] + count = badwords.length + + idx = nil + idx = params[:idx].to_i if params[:idx] + + if idx + if idx > count + m.reply "No such badword \##{idx}" + return + end + badwords.delete_at(idx-1) + else + channel = params[:channel].downcase + + regexp = make_badword_rx(params[:regexp]) + debug "Trying to remove #{regexp.inspect} from #{badwords.inspect}" + + badwords.each { |badword| + next unless ['all', badword.channel].include?(channel) + debug "Removing #{badword.inspect}" if badword == regexp + badwords.delete(badword) if badword == regexp + } + end + + @registry[:badwords] = badwords + if count > badwords.length + m.okay + else + m.reply "No matching badword #{regexp} found" + end + end + + def add_whitelist(m, params=nil) + begin + host, channel = m.server.new_netmask(params[:host]), params[:channel].downcase + + # TODO check if a whitelist entry for this host already exists + whitelist = @registry[:whitelist] + whitelist << WhitelistEntry.new(host, channel) + @registry[:whitelist] = whitelist + + m.okay + rescue + error $! + m.reply $! + end + end + + def list_whitelist(m, params=nil) + m.reply "whitelist entries: #{@registry[:whitelist].length}" + @registry[:whitelist].each_with_index { |auto, idx| + m.reply "\##{idx+1}: #{auto.host} | #{auto.channel}" + } + end + + def rm_whitelist(m, params=nil) + wl = @registry[:whitelist] + count = wl.length + + idx = nil + idx = params[:idx].to_i if params[:idx] + + if idx + if idx > count + m.reply "No such whitelist entry \##{idx}" + return + end + wl.delete_at(idx-1) + else + begin + host = m.server.new_netmask(params[:host]) + channel = params[:channel].downcase + + wl.each { |rule| + next unless ['all', rule.channel].include?(channel) + wl.delete rule if rule.host == host + } + rescue + error $! + m.reply $! + end + end + @registry[:whitelist] = wl + if count > whitelist.length + m.okay + else + m.reply "No host matching #{host}" + end + end + + private + def check_channel(m, strchannel) + begin + raise "must specify channel if using privmsg" if m.private? and not strchannel + channel = m.server.channel(strchannel) || m.target + raise "I am not in that channel" unless channel.has_user?(@bot.nick) + + return channel + rescue + error $! + m.reply $! + end + end + + def do_cmd(action, nick, channel, timer_in=nil, reason=nil) + case timer_in + when nil + timer = 0 + when /^(\d+)s$/ + timer = $1.to_i + when /^(\d+)m$/ + timer = $1.to_i * 60 + when /^(\d+)h$/ + timer = $1.to_i * 60 * 60 + when /^(\d+)d$/ + timer = $1.to_i * 60 * 60 * 24 + else + raise "Wrong time specifications" + end + + case action + when :ban + set_mode(channel, "+b", nick) + @bot.timer.add_once(timer) { set_mode(channel, "-b", nick) } if timer > 0 + when :unban + set_mode(channel, "-b", nick) + when :kick + do_kick(channel, nick, reason) + when :kickban + set_mode(channel, "+b", nick) + @bot.timer.add_once(timer) { set_mode(channel, "-b", nick) } if timer > 0 + do_kick(channel, nick, reason) + when :silence, :quiet + set_mode(channel, "+q", nick) + @bot.timer.add_once(timer) { set_mode(channel, "-q", nick) } if timer > 0 + when :unsilence, :unquiet + set_mode(channel, "-q", nick) + end + end + + def set_mode(channel, mode, nick) + host = channel.has_user?(nick) ? "*!*@" + channel.get_user(nick).host : nick + @bot.mode(channel, mode, host) + end + + def do_kick(channel, nick, reason="") + @bot.kick(channel, nick, reason) + end +end + +plugin = BansPlugin.new + +plugin.default_auth( 'act', false ) +plugin.default_auth( 'edit', false ) +plugin.default_auth( 'list', true ) + +plugin.map 'ban :nick :timer :channel', :action => 'ban_user', + :requirements => {:timer => BansPlugin::TimerRe, :channel => BansPlugin::ChannelRe}, + :defaults => {:timer => nil, :channel => nil}, + :auth_path => 'act' +plugin.map 'unban :nick :channel', :action => 'unban_user', + :requirements => {:channel => BansPlugin::ChannelRe}, + :defaults => {:channel => nil}, + :auth_path => 'act' +plugin.map 'kick :nick :channel *reason', :action => 'kick_user', + :requirements => {:channel => BansPlugin::ChannelRe}, + :defaults => {:channel => nil, :reason => 'requested'}, + :auth_path => 'act' +plugin.map 'kickban :nick :timer :channel *reason', :action => 'kickban_user', + :requirements => {:timer => BansPlugin::TimerRe, :channel => BansPlugin::ChannelRe}, + :defaults => {:timer => nil, :channel => nil, :reason => 'requested'}, + :auth_path => 'act' +plugin.map 'silence :nick :timer :channel', :action => 'silence_user', + :requirements => {:timer => BansPlugin::TimerRe, :channel => BansPlugin::ChannelRe}, + :defaults => {:timer => nil, :channel => nil}, + :auth_path => 'act' +plugin.map 'unsilence :nick :channel', :action => 'unsilence_user', + :requirements => {:channel => BansPlugin::ChannelRe}, + :defaults => {:channel => nil}, + :auth_path => 'act' + +plugin.map 'bans add onjoin :host :action :channel *reason', :action => 'add_onjoin', + :requirements => {:action => BansPlugin::ActionRe, :channel => BansPlugin::ChannelAllRe}, + :defaults => {:action => 'kickban', :channel => 'all', :reason => 'netmask not welcome'}, + :auth_path => 'edit::onjoin' +plugin.map 'bans rm onjoin index :idx', :action => 'rm_onjoin', + :requirements => {:num => BansPlugin::IdxRe}, + :auth_path => 'edit::onjoin' +plugin.map 'bans rm onjoin :host :channel', :action => 'rm_onjoin', + :requirements => {:channel => BansPlugin::ChannelAllRe}, + :defaults => {:channel => 'all'}, + :auth_path => 'edit::onjoin' +plugin.map 'bans list onjoin[s]', :action => 'list_onjoin', + :auth_path => 'list::onjoin' + +plugin.map 'bans add badword :regexp :action :timer :channel *reason', :action => 'add_badword', + :requirements => {:action => BansPlugin::ActionRe, :timer => BansPlugin::TimerRe, :channel => BansPlugin::ChannelAllRe}, + :defaults => {:action => 'silence', :timer => "0s", :channel => 'all', :reason => 'bad word'}, + :auth_path => 'edit::badword' +plugin.map 'bans rm badword index :idx', :action => 'rm_badword', + :requirements => {:num => BansPlugin::IdxRe}, + :auth_path => 'edit::badword' +plugin.map 'bans rm badword :regexp :channel', :action => 'rm_badword', + :requirements => {:channel => BansPlugin::ChannelAllRe}, + :defaults => {:channel => 'all'}, + :auth_path => 'edit::badword' +plugin.map 'bans list badword[s]', :action => 'list_badword', + :auth_path => 'list::badword' + +plugin.map 'bans add whitelist :host :channel', :action => 'add_whitelist', + :requirements => {:channel => BansPlugin::ChannelAllRe}, + :defaults => {:channel => 'all'}, + :auth_path => 'edit::whitelist' +plugin.map 'bans rm whitelist index :idx', :action => 'rm_whitelist', + :requirements => {:num => BansPlugin::IdxRe}, + :auth_path => 'edit::whitelist' +plugin.map 'bans rm whitelist :host :channel', :action => 'rm_whitelist', + :requirements => {:channel => BansPlugin::ChannelAllRe}, + :defaults => {:channel => 'all'}, + :auth_path => 'edit::whitelist' +plugin.map 'bans list whitelist', :action => 'list_whitelist', + :auth_path => 'list::whitelist' + diff --git a/data/rbot/plugins/deepthoughts.rb b/data/rbot/plugins/deepthoughts.rb index 87ccab2d..b1171bae 100644 --- a/data/rbot/plugins/deepthoughts.rb +++ b/data/rbot/plugins/deepthoughts.rb @@ -1,89 +1,89 @@ -THOUGHTS = [
- 'It takes a big man to cry, but it takes a bigger man to laugh at that man.',
- "If I ever get real rich, I hope I'm not real mean to poor people, like I am now.",
- 'To me, it\'s always a good idea to always carry two sacks of something when you walk around. That way, if anybody says, "Hey, can you give me a hand?," you can say, "Sorry, got these sacks."',
- 'Maybe in order to understand mankind we have to look at that word itself. MANKIND. Basically, it\'s made up of two separate words "mank" and "ind." What do these words mean? It\'s a mystery and that\'s why so is mankind.',
- "Anytime I see something screech across a room and latch onto someone's neck, and the guy screams and tries to get it off, I have to laugh, because what is that thing.",
- "If you're in a war, instead of throwing a hand grenade at the enemy, throw one of those small pumpkins. Maybe it'll make everyone think how stupid war is, and while they are thinking, you can throw a real grenade at them.",
- 'One thing kids like is to be tricked. For instance, I was going to take my nephew to Disneyland, but instead I drove him to an old burned-out warehouse. "Oh no," I said, "Disneyland burned down." He cried and cried, but I think that deep down he thought it was a pretty good joke. I started to drive over to the real Disneyland, but it was getting pretty late.',
- "To me, clowns aren't funny. In fact, they're kinda scary. I've wondered where this started and I think it goes back to the time I went to the circus and a clown killed my dad.",
- "I can picture in my mind a world without war, a world without hate. And I can picture us attacking that world, because they'd never expect it.",
- "When I was a kid my favorite relative was Uncle Caveman. After school we'd all go play in his cave, and every once in a while he would eat one of us. It wasn't until later that I found out that Uncle Caveman was a bear.",
- 'Instead of trying to build newer and bigger weapons of destruction, we should be thinking about getting more use out of the ones we already have.',
- 'If a kid asks where rain comes from, I think a cute thing to tell him is, "God is crying." And if he asks why God is crying, another cute thing to tell him is, "Probably because of something you did."',
- 'When I found the skull in the woods, the first thing I did was call the police. But then I got curious about it. I picked it up, and started wondering who this person was, and why he had deer horns.',
- "Instead of a trap door, what about a trap window? The guy looks out it, and if he leans too far, he falls out. Wait. I guess that's like a regular window.",
- 'Laurie got offended that I used the word "puke." But to me, that\'s what her dinner tasted like.',
- 'If you want to be the popular one at a party, here\'s a good thing to do: Go up to some people who are talking and laughing and say, "Well, technically that\'s illegal." It might fit in with what somebody just said. And even if it doesn\'t, so what, I hate this stupid party.',
- "If you ever drop your keys into a river of molten lava, let'em go, because, man, they're gone.",
- "I bet a funny thing about driving a car off a cliff is, while you're in midair, you still hit those brakes! Hey, better try the emergency brake!",
- "When you die, if you get a choice between going to regular heaven or pie heaven, choose pie heaven. It might be a trick, but if it's not, mmmmmmm, boy.",
- 'Dad always thought laughter was the best medicine, which I guess is why several of us died of tuberculosis.',
- "If you ever fall off the Sears Tower, just go real limp, because maybe you'll look like a dummy and people will try to catch you because, hey, free dummy.",
- 'I think a good product would be "Baby Duck Hat." It\'s a fake baby duck, which you strap on top of your head. Then you go swimming underwater until you find a mommy duck and her babies, and you join them. Then all of the sudden, you stand up out of the water and roar like Godzilla. Man those ducks really take off! Also Baby Duck Hat is good for parties.',
- "Sometimes I think I'd be better off dead. No, wait, not me, you.",
- 'I hope that someday we will be able to put away our fears and prejudices and just laugh at people.',
- "I believe in making the world safe for our children, but not our children's children, because I don't think children should be having sex.",
- 'I hope that after I die, people will say of me: "That guy sure owed me a lot of money."',
- "One thing vampire children have to be taught early on is, don't run with a wooden stake.",
- 'It makes me mad when people say I turned and ran like a scared rabbit. Maybe it was like an angry rabbit, who was going to fight in another fight, away from the first fight.',
- "Broken promises don't upset me. I just think, why did they believe me?",
- "How come the dove gets to be the peace symbol? How about the pillow? It has more feathers than the dove, and it doesn't have that dangerous beak.",
- "If you define cowardice as running away at the first sign of danger, screaming and tripping and begging for mercy, then yes, Mr. Brave man, I guess I'm a coward.",
- 'During the Middle Ages, probably one of the biggest mistakes was not putting on your armor because you were "just going down to the corner."',
- 'If I lived back in the wild west days, instead of carrying a six-gun in my holster, I\'d carry a soldering iron. That way, if some smart-aleck cowboy said something like "Hey, look. He\'s carrying a soldering iron!" and started laughing, and everybody else started laughing, I could just say, "That\'s right, it\'s a soldering iron. The soldering iron of justice." Then everybody would get real quiet and ashamed, because they had made fun of the soldering iron of justice, and I could probably hit them up for a free drink.',
- "I hope if dogs ever take over the world and they choose a king, they don't just go by size, because I bet there are some Chihuahuas with some good ideas.",
- "Sometimes I think you have to march right in and demand your rights, even if you don't know what your rights are, or who the person is you're talking to. Then on the way out, slam the door.",
- "Is there anything more beautiful than a beautiful, beautiful flamingo, flying across in front of a beautiful sunset? And he's carrying a beautiful rose in his beak, and also he's carrying a very beautiful painting with his feet. And also, you're drunk.",
- "I wish I had a dollar for every time I spent a dollar, because then, yahoo!, I'd have all my money back.",
- 'If I was the head of a country that lost a war, and I had to sign a peace treaty, just as I was signing, I\'d glance over the treaty and then suddenly act surprised. "Wait a minute! I thought we won!"',
- "Once while walking through the mall a guy came up to me and said, 'Hey, how's it going?' So I grabbed his arm and twisted it up behind his head and said 'Now who's asking the questions?'",
- 'The face of a child can say it all, especially the mouth part of the face.',
- 'Why do the caterpillar and the ant have to be enemies? One eats leaves, and the other eats caterpillars. Oh, I see now.',
- "We used to laugh at Grandpa when he'd head off and go fishing. But we wouldn't be laughing that evening when he'd come back with some whore he picked up in town.",
- 'I can\'t stand cheap people. It makes me real mad when someone says something like, "Hey, when are you going to pay me that $100 you owe me?" or "Do you have that $50 you borrowed?" Man, quit being so cheap!',
- "If you think a weakness can be turned into a strength, I hate to tell you this, but that's another weakness.",
- "I'd like to see a nature film where an eagle swoops down and pulls a fish out of a lake, and then maybe he's flying along, low to the ground, and the fish pulls a worm out of the ground. Now that's a documentary!",
- 'I hope they never find out that lightning has a lot of vitamins in it, because do you hide from it or not?',
- 'Many people never stop to realize that a tree is a living thing, not that different from a tall, leafy dog that has roots and is very quiet.',
- 'If you get invited to your first orgy, don\'t just show up nude. That\'s a common mistake. You have to let nudity "happen."',
- "To me, boxing is like a ballet, except there's no music, no choreography and the dancers hit each other.",
- "One thing a computer can do that most humans can't is be sealed up in a cardboard box and sit in a warehouse.",
- "Whenever you read a good book, it's like the author is right there, in the room talking to you, which is why I don't like to read good books.",
- "Contrary to what most people say, the most dangerous animal in the world is not the lion or the tiger or even the elephant. It's a shark riding on an elephant's back, just trampling and eating everything they see.",
- 'Somebody told me how frightening it was how much topsoil we are losing each year, but I told that story around the campfire and nobody got scared.',
- "If you go parachuting, and your parachute doesn't open, and you friends are all watching you fall, I think a funny gag would be to pretend you were swimming.",
- "Whenever I see an old lady slip and fall on a wet sidewalk, my first instinct is to laugh. But then I think, what if I was an ant and she fell on me. Then it wouldn't seem quite so funny.",
- "Consider the daffodil. And while you're doing that, I'll be over here, looking through your stuff.",
- 'I think there should be something in science called the "reindeer effect." I don\'t know what it would be, but I think it\'d be good to hear someone say, "Gentlemen, what we have here is a terrifying example of the reindeer effect."',
- "If you go through a lot of hammers each month, I don't think it necessarily means you're a hard worker. It may just mean that you have a lot to learn about proper hammer maintenance.",
- "You know what would make a good story? Something about a clown who make people happy, but inside he's real sad. Also, he has severe diarrhea.",
- 'I wish I had a Kryptonite cross, because then you could keep both Dracula AND Superman away.',
- 'Once when I was in Hawaii, on the island of Kauai, I met a mysterious old stranger. He said he was about to die and wanted to tell someone about the treasure. I said, "Okay, as long as it\'s not a long story. Some of us have a plane to catch, you know." He told us about his life and all, and I thought: "This story isn\'t too long." But then, he kept going, and I started thinking, "Uh-oh, this story is getting long." But then the story was over, and I said to myself: "You know, that story wasn\'t too long after all." I forget what the story was about, but there was a good movie on the plane. It was a little long, though.',
- "A man doesn't automatically get my respect. He has to get down in the dirt and beg for it.",
- "Love can sweep you off your feet and carry you along in a way you've never known before. But the ride always ends, and you end up feeling lonely and bitter. Wait. It's not love I'm describing. I'm thinking of a monorail.",
- "For mad scientists who keep brains in jars, here's a tip: Why not add a slice of lemon to each jar, for freshness.",
- 'Probably the earliest fly swatters were nothing more than some sort of striking surface attached to the end of a long stick.',
- 'You know something that would really make me applaud? A guy gets stuck in quicksand, then sinks, then suddenly comes shooting out, riding on water skis! How do they do that?!',
- 'I think somebody should come up with a way to breed a very large shrimp. That way, you could ride him, then after you camped at night, you could eat him. How about it, science?',
- "I wouldn't be surprised if someday some fishermen caught a big shark and cut it open, and there inside was a whole person. Then they cut the person open, and in him is a little baby shark. And in the baby shark there isn't a person, because it would be too small. But there's a little doll or something, like a Johnny Combat little toy guy---something like that.",
- 'We like to praise birds for flying. But how much of it is actually flying, and how much of it is just sort of coasting from the previous flap?',
- 'Something tells me that the first mousetrap wasn\'t designed to catch mice at all, but to protect little cheese "gems" from burglars.',
- 'I guess we were all guilty, in a way. We all shot him, we all skinned him, and we all got a complimentary bumper sticker that said, "I helped skin Bob."',
- "If you ever reach total enlightenment while you're drinking a beer, I bet it makes beer shoot out your nose.",
- "I guess I kinda lost control, because in the middle of the play I ran up and lit the evil puppet villain on fire. No, I didn't. Just kidding. I just said that to help illustrate one of the human emotions, which is freaking out. Another emotion is greed, as when you kill someone for money, or something like that. Another emotion is generosity, as when you pay someone double what he paid for his stupid puppet.",
- 'Of all the tall tales, I think my favorite is the one about Eli Whitney and the interchangeable parts.',
-]
-
-class DeepThoughts < Plugin
- def help(plugin, topic="")
- "deepthought => think a deep thought."
- end
-
- def deepthought(m, params)
- m.reply THOUGHTS.pick_one
- end
-end
-
-plugin = DeepThoughts.new
-plugin.map 'deepthought'
+THOUGHTS = [ + 'It takes a big man to cry, but it takes a bigger man to laugh at that man.', + "If I ever get real rich, I hope I'm not real mean to poor people, like I am now.", + 'To me, it\'s always a good idea to always carry two sacks of something when you walk around. That way, if anybody says, "Hey, can you give me a hand?," you can say, "Sorry, got these sacks."', + 'Maybe in order to understand mankind we have to look at that word itself. MANKIND. Basically, it\'s made up of two separate words "mank" and "ind." What do these words mean? It\'s a mystery and that\'s why so is mankind.', + "Anytime I see something screech across a room and latch onto someone's neck, and the guy screams and tries to get it off, I have to laugh, because what is that thing.", + "If you're in a war, instead of throwing a hand grenade at the enemy, throw one of those small pumpkins. Maybe it'll make everyone think how stupid war is, and while they are thinking, you can throw a real grenade at them.", + 'One thing kids like is to be tricked. For instance, I was going to take my nephew to Disneyland, but instead I drove him to an old burned-out warehouse. "Oh no," I said, "Disneyland burned down." He cried and cried, but I think that deep down he thought it was a pretty good joke. I started to drive over to the real Disneyland, but it was getting pretty late.', + "To me, clowns aren't funny. In fact, they're kinda scary. I've wondered where this started and I think it goes back to the time I went to the circus and a clown killed my dad.", + "I can picture in my mind a world without war, a world without hate. And I can picture us attacking that world, because they'd never expect it.", + "When I was a kid my favorite relative was Uncle Caveman. After school we'd all go play in his cave, and every once in a while he would eat one of us. It wasn't until later that I found out that Uncle Caveman was a bear.", + 'Instead of trying to build newer and bigger weapons of destruction, we should be thinking about getting more use out of the ones we already have.', + 'If a kid asks where rain comes from, I think a cute thing to tell him is, "God is crying." And if he asks why God is crying, another cute thing to tell him is, "Probably because of something you did."', + 'When I found the skull in the woods, the first thing I did was call the police. But then I got curious about it. I picked it up, and started wondering who this person was, and why he had deer horns.', + "Instead of a trap door, what about a trap window? The guy looks out it, and if he leans too far, he falls out. Wait. I guess that's like a regular window.", + 'Laurie got offended that I used the word "puke." But to me, that\'s what her dinner tasted like.', + 'If you want to be the popular one at a party, here\'s a good thing to do: Go up to some people who are talking and laughing and say, "Well, technically that\'s illegal." It might fit in with what somebody just said. And even if it doesn\'t, so what, I hate this stupid party.', + "If you ever drop your keys into a river of molten lava, let'em go, because, man, they're gone.", + "I bet a funny thing about driving a car off a cliff is, while you're in midair, you still hit those brakes! Hey, better try the emergency brake!", + "When you die, if you get a choice between going to regular heaven or pie heaven, choose pie heaven. It might be a trick, but if it's not, mmmmmmm, boy.", + 'Dad always thought laughter was the best medicine, which I guess is why several of us died of tuberculosis.', + "If you ever fall off the Sears Tower, just go real limp, because maybe you'll look like a dummy and people will try to catch you because, hey, free dummy.", + 'I think a good product would be "Baby Duck Hat." It\'s a fake baby duck, which you strap on top of your head. Then you go swimming underwater until you find a mommy duck and her babies, and you join them. Then all of the sudden, you stand up out of the water and roar like Godzilla. Man those ducks really take off! Also Baby Duck Hat is good for parties.', + "Sometimes I think I'd be better off dead. No, wait, not me, you.", + 'I hope that someday we will be able to put away our fears and prejudices and just laugh at people.', + "I believe in making the world safe for our children, but not our children's children, because I don't think children should be having sex.", + 'I hope that after I die, people will say of me: "That guy sure owed me a lot of money."', + "One thing vampire children have to be taught early on is, don't run with a wooden stake.", + 'It makes me mad when people say I turned and ran like a scared rabbit. Maybe it was like an angry rabbit, who was going to fight in another fight, away from the first fight.', + "Broken promises don't upset me. I just think, why did they believe me?", + "How come the dove gets to be the peace symbol? How about the pillow? It has more feathers than the dove, and it doesn't have that dangerous beak.", + "If you define cowardice as running away at the first sign of danger, screaming and tripping and begging for mercy, then yes, Mr. Brave man, I guess I'm a coward.", + 'During the Middle Ages, probably one of the biggest mistakes was not putting on your armor because you were "just going down to the corner."', + 'If I lived back in the wild west days, instead of carrying a six-gun in my holster, I\'d carry a soldering iron. That way, if some smart-aleck cowboy said something like "Hey, look. He\'s carrying a soldering iron!" and started laughing, and everybody else started laughing, I could just say, "That\'s right, it\'s a soldering iron. The soldering iron of justice." Then everybody would get real quiet and ashamed, because they had made fun of the soldering iron of justice, and I could probably hit them up for a free drink.', + "I hope if dogs ever take over the world and they choose a king, they don't just go by size, because I bet there are some Chihuahuas with some good ideas.", + "Sometimes I think you have to march right in and demand your rights, even if you don't know what your rights are, or who the person is you're talking to. Then on the way out, slam the door.", + "Is there anything more beautiful than a beautiful, beautiful flamingo, flying across in front of a beautiful sunset? And he's carrying a beautiful rose in his beak, and also he's carrying a very beautiful painting with his feet. And also, you're drunk.", + "I wish I had a dollar for every time I spent a dollar, because then, yahoo!, I'd have all my money back.", + 'If I was the head of a country that lost a war, and I had to sign a peace treaty, just as I was signing, I\'d glance over the treaty and then suddenly act surprised. "Wait a minute! I thought we won!"', + "Once while walking through the mall a guy came up to me and said, 'Hey, how's it going?' So I grabbed his arm and twisted it up behind his head and said 'Now who's asking the questions?'", + 'The face of a child can say it all, especially the mouth part of the face.', + 'Why do the caterpillar and the ant have to be enemies? One eats leaves, and the other eats caterpillars. Oh, I see now.', + "We used to laugh at Grandpa when he'd head off and go fishing. But we wouldn't be laughing that evening when he'd come back with some whore he picked up in town.", + 'I can\'t stand cheap people. It makes me real mad when someone says something like, "Hey, when are you going to pay me that $100 you owe me?" or "Do you have that $50 you borrowed?" Man, quit being so cheap!', + "If you think a weakness can be turned into a strength, I hate to tell you this, but that's another weakness.", + "I'd like to see a nature film where an eagle swoops down and pulls a fish out of a lake, and then maybe he's flying along, low to the ground, and the fish pulls a worm out of the ground. Now that's a documentary!", + 'I hope they never find out that lightning has a lot of vitamins in it, because do you hide from it or not?', + 'Many people never stop to realize that a tree is a living thing, not that different from a tall, leafy dog that has roots and is very quiet.', + 'If you get invited to your first orgy, don\'t just show up nude. That\'s a common mistake. You have to let nudity "happen."', + "To me, boxing is like a ballet, except there's no music, no choreography and the dancers hit each other.", + "One thing a computer can do that most humans can't is be sealed up in a cardboard box and sit in a warehouse.", + "Whenever you read a good book, it's like the author is right there, in the room talking to you, which is why I don't like to read good books.", + "Contrary to what most people say, the most dangerous animal in the world is not the lion or the tiger or even the elephant. It's a shark riding on an elephant's back, just trampling and eating everything they see.", + 'Somebody told me how frightening it was how much topsoil we are losing each year, but I told that story around the campfire and nobody got scared.', + "If you go parachuting, and your parachute doesn't open, and you friends are all watching you fall, I think a funny gag would be to pretend you were swimming.", + "Whenever I see an old lady slip and fall on a wet sidewalk, my first instinct is to laugh. But then I think, what if I was an ant and she fell on me. Then it wouldn't seem quite so funny.", + "Consider the daffodil. And while you're doing that, I'll be over here, looking through your stuff.", + 'I think there should be something in science called the "reindeer effect." I don\'t know what it would be, but I think it\'d be good to hear someone say, "Gentlemen, what we have here is a terrifying example of the reindeer effect."', + "If you go through a lot of hammers each month, I don't think it necessarily means you're a hard worker. It may just mean that you have a lot to learn about proper hammer maintenance.", + "You know what would make a good story? Something about a clown who make people happy, but inside he's real sad. Also, he has severe diarrhea.", + 'I wish I had a Kryptonite cross, because then you could keep both Dracula AND Superman away.', + 'Once when I was in Hawaii, on the island of Kauai, I met a mysterious old stranger. He said he was about to die and wanted to tell someone about the treasure. I said, "Okay, as long as it\'s not a long story. Some of us have a plane to catch, you know." He told us about his life and all, and I thought: "This story isn\'t too long." But then, he kept going, and I started thinking, "Uh-oh, this story is getting long." But then the story was over, and I said to myself: "You know, that story wasn\'t too long after all." I forget what the story was about, but there was a good movie on the plane. It was a little long, though.', + "A man doesn't automatically get my respect. He has to get down in the dirt and beg for it.", + "Love can sweep you off your feet and carry you along in a way you've never known before. But the ride always ends, and you end up feeling lonely and bitter. Wait. It's not love I'm describing. I'm thinking of a monorail.", + "For mad scientists who keep brains in jars, here's a tip: Why not add a slice of lemon to each jar, for freshness.", + 'Probably the earliest fly swatters were nothing more than some sort of striking surface attached to the end of a long stick.', + 'You know something that would really make me applaud? A guy gets stuck in quicksand, then sinks, then suddenly comes shooting out, riding on water skis! How do they do that?!', + 'I think somebody should come up with a way to breed a very large shrimp. That way, you could ride him, then after you camped at night, you could eat him. How about it, science?', + "I wouldn't be surprised if someday some fishermen caught a big shark and cut it open, and there inside was a whole person. Then they cut the person open, and in him is a little baby shark. And in the baby shark there isn't a person, because it would be too small. But there's a little doll or something, like a Johnny Combat little toy guy---something like that.", + 'We like to praise birds for flying. But how much of it is actually flying, and how much of it is just sort of coasting from the previous flap?', + 'Something tells me that the first mousetrap wasn\'t designed to catch mice at all, but to protect little cheese "gems" from burglars.', + 'I guess we were all guilty, in a way. We all shot him, we all skinned him, and we all got a complimentary bumper sticker that said, "I helped skin Bob."', + "If you ever reach total enlightenment while you're drinking a beer, I bet it makes beer shoot out your nose.", + "I guess I kinda lost control, because in the middle of the play I ran up and lit the evil puppet villain on fire. No, I didn't. Just kidding. I just said that to help illustrate one of the human emotions, which is freaking out. Another emotion is greed, as when you kill someone for money, or something like that. Another emotion is generosity, as when you pay someone double what he paid for his stupid puppet.", + 'Of all the tall tales, I think my favorite is the one about Eli Whitney and the interchangeable parts.', +] + +class DeepThoughts < Plugin + def help(plugin, topic="") + "deepthought => think a deep thought." + end + + def deepthought(m, params) + m.reply THOUGHTS.pick_one + end +end + +plugin = DeepThoughts.new +plugin.map 'deepthought' diff --git a/data/rbot/plugins/dict.rb b/data/rbot/plugins/dict.rb index ca5f2588..db779889 100644 --- a/data/rbot/plugins/dict.rb +++ b/data/rbot/plugins/dict.rb @@ -1,174 +1,174 @@ -#-- vim:sw=2:et
-#++
-#
-# :title: Dictionary lookup plugin for rbot
-#
-# Author:: Giuseppe "Oblomov" Bilotta <giuseppe.bilotta@gmail.com>
-# Copyright:: (C) 2006-2007 Giuseppe Bilotta
-# License:: GPL v2
-#
-# Provides a link to the definition of a word in one of the supported
-# dictionaries. Currently available are
-# * the Oxford dictionary for (British) English
-# * the De Mauro/Paravia dictionary for Italian
-# * the Chambers dictionary for English (accepts both US and UK)
-#
-# Other plugins can use this one to check if a given word is valid in italian
-# or english by using the is_italian?/is_british?/is_english? methods
-#
-# TODO: cache results and reuse them if get_cached returns a cache copy
-
-DEMAURO_LEMMA = /<anchor>(.*?)(?: - (.*?))<go href="lemma.php\?ID=(\d+)"\/><\/anchor>/
-CHAMBERS_LEMMA = /<p><span class="hwd">(.*?)<\/span> <span class="psa">(.*?)<\/span>(.*?)<\/p>/
-
-class DictPlugin < Plugin
- Config.register Config::IntegerValue.new('dict.hits',
- :default => 3,
- :desc => "Number of hits to return from a dictionary lookup")
- Config.register Config::IntegerValue.new('dict.first_par',
- :default => 0,
- :desc => "When set to n > 0, the bot will return the first paragraph from the first n dictionary hits")
-
- def initialize
- super
- @dmurl = "http://www.demauroparavia.it/"
- @dmwapurl = "http://wap.demauroparavia.it/index.php?lemma=%s"
- @dmwaplemma = "http://wap.demauroparavia.it/lemma.php?ID=%s"
- @oxurl = "http://www.askoxford.com/concise_oed/%s"
- @chambersurl = "http://www.chambersharrap.co.uk/chambers/features/chref/chref.py/main?query=%s&title=21st"
- end
-
-
- def help(plugin, topic="")
- case topic
- when "demauro"
- return "demauro <word> => provides a link to the definition of <word> from the De Mauro/Paravia dictionary"
- when "oxford"
- return "oxford <word> => provides a link to the definition of <word> (it can also be an expression) from the Concise Oxford dictionary"
- when "chambers"
- return "chambers <word> => provides a link to the definition of <word> (it can also be an expression) from the Chambers 21st Century Dictionary"
- end
- return "<dictionary> <word>: check for <word> on <dictionary> where <dictionary> can be one of: demauro, oxford, chambers"
- end
-
- def demauro(m, params)
- justcheck = params[:justcheck]
-
- word = params[:word].downcase
- url = @dmwapurl % CGI.escape(word)
- xml = nil
- info = @bot.httputil.get_response(url) rescue nil
- xml = info.body if info
- if xml.nil?
- info = info ? " (#{info.code} - #{info.message})" : ""
- return false if justcheck
- m.reply "An error occurred while looking for #{word}#{info}"
- return
- end
- if xml=~ /Non ho trovato occorrenze per/
- return false if justcheck
- m.reply "Nothing found for #{word}"
- return
- end
- entries = xml.scan(DEMAURO_LEMMA)
- text = word
- urls = []
- if not entries.transpose.first.grep(/\b#{word}\b/)
- return false if justcheck
- text += " not found. Similar words"
- end
- return true if justcheck
- text += ": "
- n = 0
- hits = @bot.config['dict.hits']
- text += entries[0...hits].map { |ar|
- n += 1
- urls << @dmwaplemma % ar[2]
- "#{n}. #{Bold}#{ar[0]}#{Bold} - #{ar[1].gsub(/<\/?em>/,'')}: #{@dmurl}#{ar[2]}"
- }.join(" | ")
- m.reply text
-
- first_pars = @bot.config['dict.first_par']
-
- return unless first_pars > 0
-
- Utils.get_first_pars urls, first_pars, :message => m,
- :strip => /^.+?\s+-\s+/
-
- end
-
- def is_italian?(word)
- return demauro(nil, :word => word, :justcheck => true)
- end
-
-
- def oxford(m, params)
- justcheck = params[:justcheck]
-
- word = params[:word].join
- [word, word + "_1"].each { |check|
- url = @oxurl % CGI.escape(check)
- if params[:british]
- url << "?view=uk"
- end
- h = @bot.httputil.get(url, :max_redir => 5)
- if h and h.match(%r!<h2>#{word}(?:<sup>1</sup>)?</h2>!)
- m.reply("#{word} : #{url}") unless justcheck
- defn = $'
- m.reply("#{Bold}%s#{Bold}: %s" % [word, defn.ircify_html(:nbsp => :space)], :overlong => :truncate)
- return true
- end
- }
- return false if justcheck
- m.reply "#{word} not found"
- end
-
- def is_british?(word)
- return oxford(nil, :word => word, :justcheck => true, :british => true)
- end
-
-
- def chambers(m, params)
- justcheck = params[:justcheck]
-
- word = params[:word].to_s.downcase
- url = @chambersurl % CGI.escape(word)
- xml = nil
- info = @bot.httputil.get_response(url) rescue nil
- xml = info.body if info
- case xml
- when nil
- info = info ? " (#{info.code} - #{info.message})" : ""
- return false if justcheck
- m.reply "An error occurred while looking for #{word}#{info}"
- return
- when /Sorry, no entries for <b>.*?<\/b> were found./
- return false if justcheck
- m.reply "Nothing found for #{word}"
- return
- when /No exact matches for <b>.*?<\/b>, but the following may be helpful./
- return false if justcheck
- m.reply "Nothing found for #{word}, but see #{url} for possible suggestions"
- return
- end
- # Else, we have a hit
- return true if justcheck
- m.reply "#{word}: #{url}"
- entries = xml.scan(CHAMBERS_LEMMA)
- hits = @bot.config['dict.hits']
- entries[0...hits].map { |ar|
- m.reply(("#{Bold}%s#{Bold} #{Underline}%s#{Underline}%s" % ar).ircify_html, :overlong => :truncate)
- }
- end
-
- def is_english?(word)
- return chambers(nil, :word => word, :justcheck => true)
- end
-
-end
-
-plugin = DictPlugin.new
-plugin.map 'demauro :word', :action => 'demauro'
-plugin.map 'oxford *word', :action => 'oxford'
-plugin.map 'chambers *word', :action => 'chambers'
-
+#-- vim:sw=2:et +#++ +# +# :title: Dictionary lookup plugin for rbot +# +# Author:: Giuseppe "Oblomov" Bilotta <giuseppe.bilotta@gmail.com> +# Copyright:: (C) 2006-2007 Giuseppe Bilotta +# License:: GPL v2 +# +# Provides a link to the definition of a word in one of the supported +# dictionaries. Currently available are +# * the Oxford dictionary for (British) English +# * the De Mauro/Paravia dictionary for Italian +# * the Chambers dictionary for English (accepts both US and UK) +# +# Other plugins can use this one to check if a given word is valid in italian +# or english by using the is_italian?/is_british?/is_english? methods +# +# TODO: cache results and reuse them if get_cached returns a cache copy + +DEMAURO_LEMMA = /<anchor>(.*?)(?: - (.*?))<go href="lemma.php\?ID=(\d+)"\/><\/anchor>/ +CHAMBERS_LEMMA = /<p><span class="hwd">(.*?)<\/span> <span class="psa">(.*?)<\/span>(.*?)<\/p>/ + +class DictPlugin < Plugin + Config.register Config::IntegerValue.new('dict.hits', + :default => 3, + :desc => "Number of hits to return from a dictionary lookup") + Config.register Config::IntegerValue.new('dict.first_par', + :default => 0, + :desc => "When set to n > 0, the bot will return the first paragraph from the first n dictionary hits") + + def initialize + super + @dmurl = "http://www.demauroparavia.it/" + @dmwapurl = "http://wap.demauroparavia.it/index.php?lemma=%s" + @dmwaplemma = "http://wap.demauroparavia.it/lemma.php?ID=%s" + @oxurl = "http://www.askoxford.com/concise_oed/%s" + @chambersurl = "http://www.chambersharrap.co.uk/chambers/features/chref/chref.py/main?query=%s&title=21st" + end + + + def help(plugin, topic="") + case topic + when "demauro" + return "demauro <word> => provides a link to the definition of <word> from the De Mauro/Paravia dictionary" + when "oxford" + return "oxford <word> => provides a link to the definition of <word> (it can also be an expression) from the Concise Oxford dictionary" + when "chambers" + return "chambers <word> => provides a link to the definition of <word> (it can also be an expression) from the Chambers 21st Century Dictionary" + end + return "<dictionary> <word>: check for <word> on <dictionary> where <dictionary> can be one of: demauro, oxford, chambers" + end + + def demauro(m, params) + justcheck = params[:justcheck] + + word = params[:word].downcase + url = @dmwapurl % CGI.escape(word) + xml = nil + info = @bot.httputil.get_response(url) rescue nil + xml = info.body if info + if xml.nil? + info = info ? " (#{info.code} - #{info.message})" : "" + return false if justcheck + m.reply "An error occurred while looking for #{word}#{info}" + return + end + if xml=~ /Non ho trovato occorrenze per/ + return false if justcheck + m.reply "Nothing found for #{word}" + return + end + entries = xml.scan(DEMAURO_LEMMA) + text = word + urls = [] + if not entries.transpose.first.grep(/\b#{word}\b/) + return false if justcheck + text += " not found. Similar words" + end + return true if justcheck + text += ": " + n = 0 + hits = @bot.config['dict.hits'] + text += entries[0...hits].map { |ar| + n += 1 + urls << @dmwaplemma % ar[2] + "#{n}. #{Bold}#{ar[0]}#{Bold} - #{ar[1].gsub(/<\/?em>/,'')}: #{@dmurl}#{ar[2]}" + }.join(" | ") + m.reply text + + first_pars = @bot.config['dict.first_par'] + + return unless first_pars > 0 + + Utils.get_first_pars urls, first_pars, :message => m, + :strip => /^.+?\s+-\s+/ + + end + + def is_italian?(word) + return demauro(nil, :word => word, :justcheck => true) + end + + + def oxford(m, params) + justcheck = params[:justcheck] + + word = params[:word].join + [word, word + "_1"].each { |check| + url = @oxurl % CGI.escape(check) + if params[:british] + url << "?view=uk" + end + h = @bot.httputil.get(url, :max_redir => 5) + if h and h.match(%r!<h2>#{word}(?:<sup>1</sup>)?</h2>!) + m.reply("#{word} : #{url}") unless justcheck + defn = $' + m.reply("#{Bold}%s#{Bold}: %s" % [word, defn.ircify_html(:nbsp => :space)], :overlong => :truncate) + return true + end + } + return false if justcheck + m.reply "#{word} not found" + end + + def is_british?(word) + return oxford(nil, :word => word, :justcheck => true, :british => true) + end + + + def chambers(m, params) + justcheck = params[:justcheck] + + word = params[:word].to_s.downcase + url = @chambersurl % CGI.escape(word) + xml = nil + info = @bot.httputil.get_response(url) rescue nil + xml = info.body if info + case xml + when nil + info = info ? " (#{info.code} - #{info.message})" : "" + return false if justcheck + m.reply "An error occurred while looking for #{word}#{info}" + return + when /Sorry, no entries for <b>.*?<\/b> were found./ + return false if justcheck + m.reply "Nothing found for #{word}" + return + when /No exact matches for <b>.*?<\/b>, but the following may be helpful./ + return false if justcheck + m.reply "Nothing found for #{word}, but see #{url} for possible suggestions" + return + end + # Else, we have a hit + return true if justcheck + m.reply "#{word}: #{url}" + entries = xml.scan(CHAMBERS_LEMMA) + hits = @bot.config['dict.hits'] + entries[0...hits].map { |ar| + m.reply(("#{Bold}%s#{Bold} #{Underline}%s#{Underline}%s" % ar).ircify_html, :overlong => :truncate) + } + end + + def is_english?(word) + return chambers(nil, :word => word, :justcheck => true) + end + +end + +plugin = DictPlugin.new +plugin.map 'demauro :word', :action => 'demauro' +plugin.map 'oxford *word', :action => 'oxford' +plugin.map 'chambers *word', :action => 'chambers' + diff --git a/data/rbot/plugins/games/azgame.rb b/data/rbot/plugins/games/azgame.rb index 495173b3..b1bb09e5 100644 --- a/data/rbot/plugins/games/azgame.rb +++ b/data/rbot/plugins/games/azgame.rb @@ -1,566 +1,566 @@ -#-- vim:sw=2:et
-#++
-#
-# :title: A-Z Game Plugin for rbot
-#
-# Author:: Giuseppe "Oblomov" Bilotta <giuseppe.bilotta@gmail.com>
-# Author:: Yaohan Chen <yaohan.chen@gmail.com>: Japanese support
-#
-# Copyright:: (C) 2006 Giuseppe Bilotta
-# Copyright:: (C) 2007 GIuseppe Bilotta, Yaohan Chen
-#
-# License:: GPL v2
-#
-# A-Z Game: guess the word by reducing the interval of allowed ones
-#
-# TODO allow manual addition of words
-
-class AzGame
-
- attr_reader :range, :word
- attr_reader :lang, :rules, :listener
- attr_accessor :tries, :total_tries, :total_failed, :failed, :winner
- def initialize(plugin, lang, rules, word)
- @plugin = plugin
- @lang = lang.to_sym
- @word = word.downcase
- @rules = rules
- @range = [@rules[:first].dup, @rules[:last].dup]
- @listener = @rules[:listener]
- @total_tries = 0
- @total_failed = 0 # not used, reported, updated
- @tries = Hash.new(0)
- @failed = Hash.new(0) # not used, not reported, updated
- @winner = nil
- def @range.to_s
- return "%s -- %s" % self
- end
- end
-
- def check(word)
- w = word.downcase
- debug "checking #{w} for #{@word} in #{@range}"
- # Since we're called threaded, bail out early if a winner
- # was assigned already
- return [:ignore, nil] if @winner
- return [:bingo, nil] if w == @word
- return [:out, @range] if w < @range.first or w > @range.last
- return [:ignore, @range] if w == @range.first or w == @range.last
- # This is potentially slow (for languages that check online)
- return [:noexist, @range] unless @plugin.send("is_#{@lang}?", w)
- debug "we like it"
- # Check again if there was a winner in the mean time,
- # and bail out if there was
- return [:ignore, nil] if @winner
- if w < @word and w > @range.first
- @range.first.replace(w)
- return [:in, @range]
- elsif w > @word and w < @range.last
- @range.last.replace(w)
- return [:in, @range]
- end
- return [:out, @range]
- end
-
-# TODO scoring: base score is t = ceil(100*exp(-((n-1)^2)/(50^2)))+p for n attempts
-# done by p players; players that didn't win but contributed
-# with a attempts will get t*a/n points
-
- include Math
-
- def score
- n = @total_tries
- p = @tries.keys.length
- t = (100*exp(-((n-1)**2)/(50.0**2))).ceil + p
- debug "Total score: #{t}"
- ret = Hash.new
- @tries.each { |k, a|
- ret[k] = [t*a/n, n_("%{count} try", "%{count} tries", a) % {:count => a}]
- }
- if @winner
- debug "replacing winner score of %d with %d" % [ret[@winner].first, t]
- tries = ret[@winner].last
- ret[@winner] = [t, _("winner, %{tries}") % {:tries => tries}]
- end
- return ret.sort_by { |h| h.last.first }.reverse
- end
-
-end
-
-class AzGamePlugin < Plugin
-
- def initialize
- super
- # if @registry.has_key?(:games)
- # @games = @registry[:games]
- # else
- @games = Hash.new
- # end
- if @registry.has_key?(:wordcache) and @registry[:wordcache]
- @wordcache = @registry[:wordcache]
- else
- @wordcache = Hash.new
- end
- debug "A-Z wordcache: #{@wordcache.pretty_inspect}"
-
- @rules = {
- :italian => {
- :good => /s\.f\.|s\.m\.|agg\.|v\.tr\.|v\.(pronom\.)?intr\./, # avv\.|pron\.|cong\.
- :bad => /var\./,
- :first => 'abaco',
- :last => 'zuzzurellone',
- :url => "http://www.demauroparavia.it/%s",
- :wapurl => "http://wap.demauroparavia.it/index.php?lemma=%s",
- :listener => /^[a-z]+$/
- },
- :english => {
- :good => /(?:singular )?noun|verb|adj/,
- :first => 'abacus',
- :last => 'zuni',
- :url => "http://www.chambersharrap.co.uk/chambers/features/chref/chref.py/main?query=%s&title=21st",
- :listener => /^[a-z]+$/
- },
- }
-
- japanese_wordlist = "#{@bot.botclass}/azgame/wordlist-japanese"
- if File.exist?(japanese_wordlist)
- words = File.readlines(japanese_wordlist) \
- .map {|line| line.strip} .uniq
- if(words.length >= 4) # something to guess
- @rules[:japanese] = {
- :good => /^\S+$/,
- :list => words,
- :first => words[0],
- :last => words[-1],
- :listener => /^\S+$/
- }
- debug "Japanese wordlist loaded, #{@rules[:japanese][:list].length} lines; first word: #{@rules[:japanese][:first]}, last word: #{@rules[:japanese][:last]}"
- end
- end
- end
-
- def save
- # @registry[:games] = @games
- @registry[:wordcache] = @wordcache
- end
-
- def listen(m)
- return unless m.kind_of?(PrivMessage)
- return if m.channel.nil? or m.address?
- k = m.channel.downcase.to_s # to_sym?
- return unless @games.key?(k)
- return if m.params
- word = m.plugin.downcase
- return unless word =~ @games[k].listener
- word_check(m, k, word)
- end
-
- def word_check(m, k, word)
- # Not really safe ... what happens
- Thread.new {
- isit = @games[k].check(word)
- case isit.first
- when :bingo
- m.reply _("%{bold}BINGO!%{bold} the word was %{underline}%{word}%{underline}. Congrats, %{bold}%{player}%{bold}!") % {:bold => Bold, :underline => Underline, :word => word, :player => m.sourcenick}
- @games[k].total_tries += 1
- @games[k].tries[m.source] += 1
- @games[k].winner = m.source
- ar = @games[k].score.inject([]) { |res, kv|
- res.push("%s: %d (%s)" % kv.flatten)
- }
- m.reply _("The game was won after %{tries} tries. Scores for this game: %{scores}") % {:tries => @games[k].total_tries, :scores => ar.join('; ')}
- @games.delete(k)
- when :out
- m.reply _("%{word} is not in the range %{bold}%{range}%{bold}") % {:word => word, :bold => Bold, :range => isit.last} if m.address?
- when :noexist
- # bail out early if the game was won in the mean time
- return if !@games[k] or @games[k].winner
- m.reply _("%{word} doesn't exist or is not acceptable for the game") % {:word => word}
- @games[k].total_failed += 1
- @games[k].failed[m.source] += 1
- when :in
- # bail out early if the game was won in the mean time
- return if !@games[k] or @games[k].winner
- m.reply _("close, but no cigar. New range: %{bold}%{range}%{bold}") % {:bold => Bold, :range => isit.last}
- @games[k].total_tries += 1
- @games[k].tries[m.source] += 1
- when :ignore
- m.reply _("%{word} is already one of the range extrema: %{range}") % {:word => word, :range => isit.last} if m.address?
- else
- m.reply _("hm, something went wrong while verifying %{word}")
- end
- }
- end
-
- def manual_word_check(m, params)
- k = m.channel.downcase.to_s
- word = params[:word].downcase
- if not @games.key?(k)
- m.reply _("no A-Z game running here, can't check if %{word} is valid, can I?")
- return
- end
- if word !~ /^\S+$/
- m.reply _("I only accept single words composed by letters only, sorry")
- return
- end
- word_check(m, k, word)
- end
-
- def stop_game(m, params)
- return if m.channel.nil? # Shouldn't happen, but you never know
- k = m.channel.downcase.to_s # to_sym?
- if @games.key?(k)
- m.reply _("the word in %{bold}%{range}%{bold} was: %{bold}%{word}%{bold}") % {:bold => Bold, :range => @games[k].range, :word => @games[k].word}
- ar = @games[k].score.inject([]) { |res, kv|
- res.push("%s: %d (%s)" % kv.flatten)
- }
- m.reply _("The game was cancelled after %{tries} tries. Scores for this game would have been: %{scores}") % {:tries => @games[k].total_tries, :scores => ar.join('; ')}
- @games.delete(k)
- else
- m.reply _("no A-Z game running in this channel ...")
- end
- end
-
- def start_game(m, params)
- return if m.channel.nil? # Shouldn't happen, but you never know
- k = m.channel.downcase.to_s # to_sym?
- unless @games.key?(k)
- lang = (params[:lang] || @bot.config['core.language']).to_sym
- method = 'random_pick_'+lang.to_s
- m.reply _("let me think ...")
- if @rules.has_key?(lang) and self.respond_to?(method)
- word = self.send(method)
- if word.empty?
- m.reply _("couldn't think of anything ...")
- return
- end
- else
- m.reply _("I can't play A-Z in %{lang}, sorry") % {:lang => lang}
- return
- end
- m.reply _("got it!")
- @games[k] = AzGame.new(self, lang, @rules[lang], word)
- end
- tr = @games[k].total_tries
- # this message building code is rewritten to make translation easier
- if tr == 0
- tr_msg = ''
- else
- f_tr = @games[k].total_failed
- if f_tr > 0
- tr_msg = _(" (after %{total_tries} and %{invalid_tries})") %
- { :total_tries => n_("%{count} try", "%{count} tries", tr) %
- {:count => tr},
- :invalid_tries => n_("%{count} invalid try", "%{count} invalid tries", tr) %
- {:count => f_tr} }
- else
- tr_msg = _(" (after %{total_tries})") %
- { :total_tries => n_("%{count} try", "%{count} tries", tr) %
- {:count => tr}}
- end
- end
-
- m.reply _("A-Z: %{bold}%{range}%{bold}") % {:bold => Bold, :range => @games[k].range} + tr_msg
- return
- end
-
- def wordlist(m, params)
- pars = params[:params]
- lang = (params[:lang] || @bot.config['core.language']).to_sym
- wc = @wordcache[lang] || Hash.new rescue Hash.new
- cmd = params[:cmd].to_sym rescue :count
- case cmd
- when :count
- m.reply n_("I have %{count} %{lang} word in my cache", "I have %{count} %{lang} words in my cache", wc.size) % {:count => wc.size, :lang => lang}
- when :show, :list
- if pars.empty?
- m.reply _("provide a regexp to match")
- return
- end
- begin
- regex = /#{pars[0]}/
- matches = wc.keys.map { |k|
- k.to_s
- }.grep(regex)
- rescue
- matches = []
- end
- if matches.size == 0
- m.reply _("no %{lang} word I know match %{pattern}") % {:lang => lang, :pattern => pars[0]}
- elsif matches.size > 25
- m.reply _("more than 25 %{lang} words I know match %{pattern}, try a stricter matching") % {:lang => lang, :pattern => pars[0]}
- else
- m.reply "#{matches.join(', ')}"
- end
- when :info
- if pars.empty?
- m.reply _("provide a word")
- return
- end
- word = pars[0].downcase.to_sym
- if not wc.key?(word)
- m.reply _("I don't know any %{lang} word %{word}") % {:lang => lang, :word => word}
- return
- end
- if wc[word].key?(:when)
- tr = _("%{word} learned from %{user} on %{date}") % {:word => word, :user => wc[word][:who], :date => wc[word][:when]}
- else
- tr = _("%{word} learned from %{user}") % {:word => word, :user => wc[word][:who]}
- end
- m.reply tr
- when :delete
- if pars.empty?
- m.reply _("provide a word")
- return
- end
- word = pars[0].downcase.to_sym
- if not wc.key?(word)
- m.reply _("I don't know any %{lang} word %{word}") % {:lang => lang, :word => word}
- return
- end
- wc.delete(word)
- @bot.okay m.replyto
- when :add
- if pars.empty?
- m.reply _("provide a word")
- return
- end
- word = pars[0].downcase.to_sym
- if wc.key?(word)
- m.reply _("I already know the %{lang} word %{word}")
- return
- end
- wc[word] = { :who => m.sourcenick, :when => Time.now }
- @bot.okay m.replyto
- else
- end
- end
-
- def is_japanese?(word)
- @rules[:japanese][:list].include?(word)
- end
-
- # return integer between min and max, inclusive
- def rand_between(min, max)
- rand(max - min + 1) + min
- end
-
- def random_pick_japanese(min=nil, max=nil)
- rules = @rules[:japanese]
- min = rules[:first] if min.nil_or_empty?
- max = rules[:last] if max.nil_or_empty?
- debug "Randomly picking word between #{min} and #{max}"
- min_index = rules[:list].index(min)
- max_index = rules[:list].index(max)
- debug "Index between #{min_index} and #{max_index}"
- index = rand_between(min_index + 1, max_index - 1)
- debug "Index generated: #{index}"
- word = rules[:list][index]
- debug "Randomly picked #{word}"
- word
- end
-
- def is_italian?(word)
- unless @wordcache.key?(:italian)
- @wordcache[:italian] = Hash.new
- end
- wc = @wordcache[:italian]
- return true if wc.key?(word.to_sym)
- rules = @rules[:italian]
- p = @bot.httputil.get(rules[:wapurl] % word, :open_timeout => 60, :read_timeout => 60)
- if not p
- error "could not connect!"
- return false
- end
- debug p
- p.scan(/<anchor>#{word} - (.*?)<go href="lemma.php\?ID=([^"]*?)"/) { |qual, url|
- debug "new word #{word} of type #{qual}"
- if qual =~ rules[:good] and qual !~ rules[:bad]
- wc[word.to_sym] = {:who => :dict}
- return true
- end
- next
- }
- return false
- end
-
- def random_pick_italian(min=nil,max=nil)
- # Try to pick a random word between min and max
- word = String.new
- min = min.to_s
- max = max.to_s
- if min > max
- m.reply "#{min} > #{max}"
- return word
- end
- rules = @rules[:italian]
- min = rules[:first] if min.empty?
- max = rules[:last] if max.empty?
- debug "looking for word between #{min.inspect} and #{max.inspect}"
- return word if min.empty? or max.empty?
- begin
- while (word <= min or word >= max or word !~ /^[a-z]+$/)
- debug "looking for word between #{min} and #{max} (prev: #{word.inspect})"
- # TODO for the time being, skip words with extended characters
- unless @wordcache.key?(:italian)
- @wordcache[:italian] = Hash.new
- end
- wc = @wordcache[:italian]
-
- if wc.size > 0
- cache_or_url = rand(2)
- if cache_or_url == 0
- debug "getting word from wordcache"
- word = wc.keys[rand(wc.size)].to_s
- next
- end
- end
-
- # TODO when doing ranges, adapt this choice
- l = ('a'..'z').to_a[rand(26)]
- debug "getting random word from dictionary, starting with letter #{l}"
- first = rules[:url] % "lettera_#{l}_0_50"
- p = @bot.httputil.get(first)
- max_page = p.match(/ \/ (\d+)<\/label>/)[1].to_i
- pp = rand(max_page)+1
- debug "getting random word from dictionary, starting with letter #{l}, page #{pp}"
- p = @bot.httputil.get(first+"&pagina=#{pp}") if pp > 1
- lemmi = Array.new
- good = rules[:good]
- bad = rules[:bad]
- # We look for a lemma composed by a single word and of length at least two
- p.scan(/<li><a href="([^"]+?)" title="consulta il lemma ([^ "][^ "]+?)">.*? (.+?)<\/li>/) { |url, prelemma, tipo|
- lemma = prelemma.downcase.to_sym
- debug "checking lemma #{lemma} (#{prelemma}) of type #{tipo} from url #{url}"
- next if wc.key?(lemma)
- case tipo
- when good
- if tipo =~ bad
- debug "refusing, #{bad}"
- next
- end
- debug "good one"
- lemmi << lemma
- wc[lemma] = {:who => :dict}
- else
- debug "refusing, not #{good}"
- end
- }
- word = lemmi[rand(lemmi.length)].to_s
- end
- rescue => e
- error "error #{e.inspect} while looking up a word"
- error e.backtrace.join("\n")
- end
- return word
- end
-
- def is_english?(word)
- unless @wordcache.key?(:english)
- @wordcache[:english] = Hash.new
- end
- wc = @wordcache[:english]
- return true if wc.key?(word.to_sym)
- rules = @rules[:english]
- p = @bot.httputil.get(rules[:url] % CGI.escape(word))
- if not p
- error "could not connect!"
- return false
- end
- debug p
- if p =~ /<span class="(?:hwd|srch)">#{word}<\/span>([^\n]+?)<span class="psa">#{rules[:good]}<\/span>/i
- debug "new word #{word}"
- wc[word.to_sym] = {:who => :dict}
- return true
- end
- return false
- end
-
- def random_pick_english(min=nil,max=nil)
- # Try to pick a random word between min and max
- word = String.new
- min = min.to_s
- max = max.to_s
- if min > max
- m.reply "#{min} > #{max}"
- return word
- end
- rules = @rules[:english]
- min = rules[:first] if min.empty?
- max = rules[:last] if max.empty?
- debug "looking for word between #{min.inspect} and #{max.inspect}"
- return word if min.empty? or max.empty?
- begin
- while (word <= min or word >= max or word !~ /^[a-z]+$/)
- debug "looking for word between #{min} and #{max} (prev: #{word.inspect})"
- # TODO for the time being, skip words with extended characters
- unless @wordcache.key?(:english)
- @wordcache[:english] = Hash.new
- end
- wc = @wordcache[:english]
-
- if wc.size > 0
- cache_or_url = rand(2)
- if cache_or_url == 0
- debug "getting word from wordcache"
- word = wc.keys[rand(wc.size)].to_s
- next
- end
- end
-
- # TODO when doing ranges, adapt this choice
- l = ('a'..'z').to_a[rand(26)]
- ll = ('a'..'z').to_a[rand(26)]
- random = [l,ll].join('*') + '*'
- debug "getting random word from dictionary, matching #{random}"
- p = @bot.httputil.get(rules[:url] % CGI.escape(random))
- debug p
- lemmi = Array.new
- good = rules[:good]
- # We look for a lemma composed by a single word and of length at least two
- p.scan(/<span class="(?:hwd|srch)">(.*?)<\/span>([^\n]+?)<span class="psa">#{rules[:good]}<\/span>/i) { |prelemma, discard|
- lemma = prelemma.downcase
- debug "checking lemma #{lemma} (#{prelemma}) and discarding #{discard}"
- next if wc.key?(lemma.to_sym)
- if lemma =~ /^[a-z]+$/
- debug "good one"
- lemmi << lemma
- wc[lemma.to_sym] = {:who => :dict}
- else
- debug "funky characters, not good"
- end
- }
- next if lemmi.empty?
- word = lemmi[rand(lemmi.length)]
- end
- rescue => e
- error "error #{e.inspect} while looking up a word"
- error e.backtrace.join("\n")
- end
- return word
- end
-
- def help(plugin, topic="")
- case topic
- when 'manage'
- return _("az [lang] word [count|list|add|delete] => manage the az wordlist for language lang (defaults to current bot language)")
- when 'cancel'
- return _("az cancel => abort current game")
- when 'check'
- return _('az check <word> => checks <word> against current game')
- when 'rules'
- return _("try to guess the word the bot is thinking of; if you guess wrong, the bot will use the new word to restrict the range of allowed words: eventually, the range will be so small around the correct word that you can't miss it")
- when 'play'
- return _("az => start a game if none is running, show the current word range otherwise; you can say 'az <language>' if you want to play in a language different from the current bot default")
- end
- return _("az topics: play, rules, cancel, manage, check")
- end
-
-end
-
-plugin = AzGamePlugin.new
-plugin.map 'az [:lang] word :cmd *params', :action=>'wordlist', :defaults => { :lang => nil, :cmd => 'count', :params => [] }, :auth_path => '!az::edit!'
-plugin.map 'az cancel', :action=>'stop_game', :private => false
-plugin.map 'az check :word', :action => 'manual_word_check', :private => false
-plugin.map 'az [play] [:lang]', :action=>'start_game', :private => false, :defaults => { :lang => nil }
-
+#-- vim:sw=2:et +#++ +# +# :title: A-Z Game Plugin for rbot +# +# Author:: Giuseppe "Oblomov" Bilotta <giuseppe.bilotta@gmail.com> +# Author:: Yaohan Chen <yaohan.chen@gmail.com>: Japanese support +# +# Copyright:: (C) 2006 Giuseppe Bilotta +# Copyright:: (C) 2007 GIuseppe Bilotta, Yaohan Chen +# +# License:: GPL v2 +# +# A-Z Game: guess the word by reducing the interval of allowed ones +# +# TODO allow manual addition of words + +class AzGame + + attr_reader :range, :word + attr_reader :lang, :rules, :listener + attr_accessor :tries, :total_tries, :total_failed, :failed, :winner + def initialize(plugin, lang, rules, word) + @plugin = plugin + @lang = lang.to_sym + @word = word.downcase + @rules = rules + @range = [@rules[:first].dup, @rules[:last].dup] + @listener = @rules[:listener] + @total_tries = 0 + @total_failed = 0 # not used, reported, updated + @tries = Hash.new(0) + @failed = Hash.new(0) # not used, not reported, updated + @winner = nil + def @range.to_s + return "%s -- %s" % self + end + end + + def check(word) + w = word.downcase + debug "checking #{w} for #{@word} in #{@range}" + # Since we're called threaded, bail out early if a winner + # was assigned already + return [:ignore, nil] if @winner + return [:bingo, nil] if w == @word + return [:out, @range] if w < @range.first or w > @range.last + return [:ignore, @range] if w == @range.first or w == @range.last + # This is potentially slow (for languages that check online) + return [:noexist, @range] unless @plugin.send("is_#{@lang}?", w) + debug "we like it" + # Check again if there was a winner in the mean time, + # and bail out if there was + return [:ignore, nil] if @winner + if w < @word and w > @range.first + @range.first.replace(w) + return [:in, @range] + elsif w > @word and w < @range.last + @range.last.replace(w) + return [:in, @range] + end + return [:out, @range] + end + +# TODO scoring: base score is t = ceil(100*exp(-((n-1)^2)/(50^2)))+p for n attempts +# done by p players; players that didn't win but contributed +# with a attempts will get t*a/n points + + include Math + + def score + n = @total_tries + p = @tries.keys.length + t = (100*exp(-((n-1)**2)/(50.0**2))).ceil + p + debug "Total score: #{t}" + ret = Hash.new + @tries.each { |k, a| + ret[k] = [t*a/n, n_("%{count} try", "%{count} tries", a) % {:count => a}] + } + if @winner + debug "replacing winner score of %d with %d" % [ret[@winner].first, t] + tries = ret[@winner].last + ret[@winner] = [t, _("winner, %{tries}") % {:tries => tries}] + end + return ret.sort_by { |h| h.last.first }.reverse + end + +end + +class AzGamePlugin < Plugin + + def initialize + super + # if @registry.has_key?(:games) + # @games = @registry[:games] + # else + @games = Hash.new + # end + if @registry.has_key?(:wordcache) and @registry[:wordcache] + @wordcache = @registry[:wordcache] + else + @wordcache = Hash.new + end + debug "A-Z wordcache: #{@wordcache.pretty_inspect}" + + @rules = { + :italian => { + :good => /s\.f\.|s\.m\.|agg\.|v\.tr\.|v\.(pronom\.)?intr\./, # avv\.|pron\.|cong\. + :bad => /var\./, + :first => 'abaco', + :last => 'zuzzurellone', + :url => "http://www.demauroparavia.it/%s", + :wapurl => "http://wap.demauroparavia.it/index.php?lemma=%s", + :listener => /^[a-z]+$/ + }, + :english => { + :good => /(?:singular )?noun|verb|adj/, + :first => 'abacus', + :last => 'zuni', + :url => "http://www.chambersharrap.co.uk/chambers/features/chref/chref.py/main?query=%s&title=21st", + :listener => /^[a-z]+$/ + }, + } + + japanese_wordlist = "#{@bot.botclass}/azgame/wordlist-japanese" + if File.exist?(japanese_wordlist) + words = File.readlines(japanese_wordlist) \ + .map {|line| line.strip} .uniq + if(words.length >= 4) # something to guess + @rules[:japanese] = { + :good => /^\S+$/, + :list => words, + :first => words[0], + :last => words[-1], + :listener => /^\S+$/ + } + debug "Japanese wordlist loaded, #{@rules[:japanese][:list].length} lines; first word: #{@rules[:japanese][:first]}, last word: #{@rules[:japanese][:last]}" + end + end + end + + def save + # @registry[:games] = @games + @registry[:wordcache] = @wordcache + end + + def listen(m) + return unless m.kind_of?(PrivMessage) + return if m.channel.nil? or m.address? + k = m.channel.downcase.to_s # to_sym? + return unless @games.key?(k) + return if m.params + word = m.plugin.downcase + return unless word =~ @games[k].listener + word_check(m, k, word) + end + + def word_check(m, k, word) + # Not really safe ... what happens + Thread.new { + isit = @games[k].check(word) + case isit.first + when :bingo + m.reply _("%{bold}BINGO!%{bold} the word was %{underline}%{word}%{underline}. Congrats, %{bold}%{player}%{bold}!") % {:bold => Bold, :underline => Underline, :word => word, :player => m.sourcenick} + @games[k].total_tries += 1 + @games[k].tries[m.source] += 1 + @games[k].winner = m.source + ar = @games[k].score.inject([]) { |res, kv| + res.push("%s: %d (%s)" % kv.flatten) + } + m.reply _("The game was won after %{tries} tries. Scores for this game: %{scores}") % {:tries => @games[k].total_tries, :scores => ar.join('; ')} + @games.delete(k) + when :out + m.reply _("%{word} is not in the range %{bold}%{range}%{bold}") % {:word => word, :bold => Bold, :range => isit.last} if m.address? + when :noexist + # bail out early if the game was won in the mean time + return if !@games[k] or @games[k].winner + m.reply _("%{word} doesn't exist or is not acceptable for the game") % {:word => word} + @games[k].total_failed += 1 + @games[k].failed[m.source] += 1 + when :in + # bail out early if the game was won in the mean time + return if !@games[k] or @games[k].winner + m.reply _("close, but no cigar. New range: %{bold}%{range}%{bold}") % {:bold => Bold, :range => isit.last} + @games[k].total_tries += 1 + @games[k].tries[m.source] += 1 + when :ignore + m.reply _("%{word} is already one of the range extrema: %{range}") % {:word => word, :range => isit.last} if m.address? + else + m.reply _("hm, something went wrong while verifying %{word}") + end + } + end + + def manual_word_check(m, params) + k = m.channel.downcase.to_s + word = params[:word].downcase + if not @games.key?(k) + m.reply _("no A-Z game running here, can't check if %{word} is valid, can I?") + return + end + if word !~ /^\S+$/ + m.reply _("I only accept single words composed by letters only, sorry") + return + end + word_check(m, k, word) + end + + def stop_game(m, params) + return if m.channel.nil? # Shouldn't happen, but you never know + k = m.channel.downcase.to_s # to_sym? + if @games.key?(k) + m.reply _("the word in %{bold}%{range}%{bold} was: %{bold}%{word}%{bold}") % {:bold => Bold, :range => @games[k].range, :word => @games[k].word} + ar = @games[k].score.inject([]) { |res, kv| + res.push("%s: %d (%s)" % kv.flatten) + } + m.reply _("The game was cancelled after %{tries} tries. Scores for this game would have been: %{scores}") % {:tries => @games[k].total_tries, :scores => ar.join('; ')} + @games.delete(k) + else + m.reply _("no A-Z game running in this channel ...") + end + end + + def start_game(m, params) + return if m.channel.nil? # Shouldn't happen, but you never know + k = m.channel.downcase.to_s # to_sym? + unless @games.key?(k) + lang = (params[:lang] || @bot.config['core.language']).to_sym + method = 'random_pick_'+lang.to_s + m.reply _("let me think ...") + if @rules.has_key?(lang) and self.respond_to?(method) + word = self.send(method) + if word.empty? + m.reply _("couldn't think of anything ...") + return + end + else + m.reply _("I can't play A-Z in %{lang}, sorry") % {:lang => lang} + return + end + m.reply _("got it!") + @games[k] = AzGame.new(self, lang, @rules[lang], word) + end + tr = @games[k].total_tries + # this message building code is rewritten to make translation easier + if tr == 0 + tr_msg = '' + else + f_tr = @games[k].total_failed + if f_tr > 0 + tr_msg = _(" (after %{total_tries} and %{invalid_tries})") % + { :total_tries => n_("%{count} try", "%{count} tries", tr) % + {:count => tr}, + :invalid_tries => n_("%{count} invalid try", "%{count} invalid tries", tr) % + {:count => f_tr} } + else + tr_msg = _(" (after %{total_tries})") % + { :total_tries => n_("%{count} try", "%{count} tries", tr) % + {:count => tr}} + end + end + + m.reply _("A-Z: %{bold}%{range}%{bold}") % {:bold => Bold, :range => @games[k].range} + tr_msg + return + end + + def wordlist(m, params) + pars = params[:params] + lang = (params[:lang] || @bot.config['core.language']).to_sym + wc = @wordcache[lang] || Hash.new rescue Hash.new + cmd = params[:cmd].to_sym rescue :count + case cmd + when :count + m.reply n_("I have %{count} %{lang} word in my cache", "I have %{count} %{lang} words in my cache", wc.size) % {:count => wc.size, :lang => lang} + when :show, :list + if pars.empty? + m.reply _("provide a regexp to match") + return + end + begin + regex = /#{pars[0]}/ + matches = wc.keys.map { |k| + k.to_s + }.grep(regex) + rescue + matches = [] + end + if matches.size == 0 + m.reply _("no %{lang} word I know match %{pattern}") % {:lang => lang, :pattern => pars[0]} + elsif matches.size > 25 + m.reply _("more than 25 %{lang} words I know match %{pattern}, try a stricter matching") % {:lang => lang, :pattern => pars[0]} + else + m.reply "#{matches.join(', ')}" + end + when :info + if pars.empty? + m.reply _("provide a word") + return + end + word = pars[0].downcase.to_sym + if not wc.key?(word) + m.reply _("I don't know any %{lang} word %{word}") % {:lang => lang, :word => word} + return + end + if wc[word].key?(:when) + tr = _("%{word} learned from %{user} on %{date}") % {:word => word, :user => wc[word][:who], :date => wc[word][:when]} + else + tr = _("%{word} learned from %{user}") % {:word => word, :user => wc[word][:who]} + end + m.reply tr + when :delete + if pars.empty? + m.reply _("provide a word") + return + end + word = pars[0].downcase.to_sym + if not wc.key?(word) + m.reply _("I don't know any %{lang} word %{word}") % {:lang => lang, :word => word} + return + end + wc.delete(word) + @bot.okay m.replyto + when :add + if pars.empty? + m.reply _("provide a word") + return + end + word = pars[0].downcase.to_sym + if wc.key?(word) + m.reply _("I already know the %{lang} word %{word}") + return + end + wc[word] = { :who => m.sourcenick, :when => Time.now } + @bot.okay m.replyto + else + end + end + + def is_japanese?(word) + @rules[:japanese][:list].include?(word) + end + + # return integer between min and max, inclusive + def rand_between(min, max) + rand(max - min + 1) + min + end + + def random_pick_japanese(min=nil, max=nil) + rules = @rules[:japanese] + min = rules[:first] if min.nil_or_empty? + max = rules[:last] if max.nil_or_empty? + debug "Randomly picking word between #{min} and #{max}" + min_index = rules[:list].index(min) + max_index = rules[:list].index(max) + debug "Index between #{min_index} and #{max_index}" + index = rand_between(min_index + 1, max_index - 1) + debug "Index generated: #{index}" + word = rules[:list][index] + debug "Randomly picked #{word}" + word + end + + def is_italian?(word) + unless @wordcache.key?(:italian) + @wordcache[:italian] = Hash.new + end + wc = @wordcache[:italian] + return true if wc.key?(word.to_sym) + rules = @rules[:italian] + p = @bot.httputil.get(rules[:wapurl] % word, :open_timeout => 60, :read_timeout => 60) + if not p + error "could not connect!" + return false + end + debug p + p.scan(/<anchor>#{word} - (.*?)<go href="lemma.php\?ID=([^"]*?)"/) { |qual, url| + debug "new word #{word} of type #{qual}" + if qual =~ rules[:good] and qual !~ rules[:bad] + wc[word.to_sym] = {:who => :dict} + return true + end + next + } + return false + end + + def random_pick_italian(min=nil,max=nil) + # Try to pick a random word between min and max + word = String.new + min = min.to_s + max = max.to_s + if min > max + m.reply "#{min} > #{max}" + return word + end + rules = @rules[:italian] + min = rules[:first] if min.empty? + max = rules[:last] if max.empty? + debug "looking for word between #{min.inspect} and #{max.inspect}" + return word if min.empty? or max.empty? + begin + while (word <= min or word >= max or word !~ /^[a-z]+$/) + debug "looking for word between #{min} and #{max} (prev: #{word.inspect})" + # TODO for the time being, skip words with extended characters + unless @wordcache.key?(:italian) + @wordcache[:italian] = Hash.new + end + wc = @wordcache[:italian] + + if wc.size > 0 + cache_or_url = rand(2) + if cache_or_url == 0 + debug "getting word from wordcache" + word = wc.keys[rand(wc.size)].to_s + next + end + end + + # TODO when doing ranges, adapt this choice + l = ('a'..'z').to_a[rand(26)] + debug "getting random word from dictionary, starting with letter #{l}" + first = rules[:url] % "lettera_#{l}_0_50" + p = @bot.httputil.get(first) + max_page = p.match(/ \/ (\d+)<\/label>/)[1].to_i + pp = rand(max_page)+1 + debug "getting random word from dictionary, starting with letter #{l}, page #{pp}" + p = @bot.httputil.get(first+"&pagina=#{pp}") if pp > 1 + lemmi = Array.new + good = rules[:good] + bad = rules[:bad] + # We look for a lemma composed by a single word and of length at least two + p.scan(/<li><a href="([^"]+?)" title="consulta il lemma ([^ "][^ "]+?)">.*? (.+?)<\/li>/) { |url, prelemma, tipo| + lemma = prelemma.downcase.to_sym + debug "checking lemma #{lemma} (#{prelemma}) of type #{tipo} from url #{url}" + next if wc.key?(lemma) + case tipo + when good + if tipo =~ bad + debug "refusing, #{bad}" + next + end + debug "good one" + lemmi << lemma + wc[lemma] = {:who => :dict} + else + debug "refusing, not #{good}" + end + } + word = lemmi[rand(lemmi.length)].to_s + end + rescue => e + error "error #{e.inspect} while looking up a word" + error e.backtrace.join("\n") + end + return word + end + + def is_english?(word) + unless @wordcache.key?(:english) + @wordcache[:english] = Hash.new + end + wc = @wordcache[:english] + return true if wc.key?(word.to_sym) + rules = @rules[:english] + p = @bot.httputil.get(rules[:url] % CGI.escape(word)) + if not p + error "could not connect!" + return false + end + debug p + if p =~ /<span class="(?:hwd|srch)">#{word}<\/span>([^\n]+?)<span class="psa">#{rules[:good]}<\/span>/i + debug "new word #{word}" + wc[word.to_sym] = {:who => :dict} + return true + end + return false + end + + def random_pick_english(min=nil,max=nil) + # Try to pick a random word between min and max + word = String.new + min = min.to_s + max = max.to_s + if min > max + m.reply "#{min} > #{max}" + return word + end + rules = @rules[:english] + min = rules[:first] if min.empty? + max = rules[:last] if max.empty? + debug "looking for word between #{min.inspect} and #{max.inspect}" + return word if min.empty? or max.empty? + begin + while (word <= min or word >= max or word !~ /^[a-z]+$/) + debug "looking for word between #{min} and #{max} (prev: #{word.inspect})" + # TODO for the time being, skip words with extended characters + unless @wordcache.key?(:english) + @wordcache[:english] = Hash.new + end + wc = @wordcache[:english] + + if wc.size > 0 + cache_or_url = rand(2) + if cache_or_url == 0 + debug "getting word from wordcache" + word = wc.keys[rand(wc.size)].to_s + next + end + end + + # TODO when doing ranges, adapt this choice + l = ('a'..'z').to_a[rand(26)] + ll = ('a'..'z').to_a[rand(26)] + random = [l,ll].join('*') + '*' + debug "getting random word from dictionary, matching #{random}" + p = @bot.httputil.get(rules[:url] % CGI.escape(random)) + debug p + lemmi = Array.new + good = rules[:good] + # We look for a lemma composed by a single word and of length at least two + p.scan(/<span class="(?:hwd|srch)">(.*?)<\/span>([^\n]+?)<span class="psa">#{rules[:good]}<\/span>/i) { |prelemma, discard| + lemma = prelemma.downcase + debug "checking lemma #{lemma} (#{prelemma}) and discarding #{discard}" + next if wc.key?(lemma.to_sym) + if lemma =~ /^[a-z]+$/ + debug "good one" + lemmi << lemma + wc[lemma.to_sym] = {:who => :dict} + else + debug "funky characters, not good" + end + } + next if lemmi.empty? + word = lemmi[rand(lemmi.length)] + end + rescue => e + error "error #{e.inspect} while looking up a word" + error e.backtrace.join("\n") + end + return word + end + + def help(plugin, topic="") + case topic + when 'manage' + return _("az [lang] word [count|list|add|delete] => manage the az wordlist for language lang (defaults to current bot language)") + when 'cancel' + return _("az cancel => abort current game") + when 'check' + return _('az check <word> => checks <word> against current game') + when 'rules' + return _("try to guess the word the bot is thinking of; if you guess wrong, the bot will use the new word to restrict the range of allowed words: eventually, the range will be so small around the correct word that you can't miss it") + when 'play' + return _("az => start a game if none is running, show the current word range otherwise; you can say 'az <language>' if you want to play in a language different from the current bot default") + end + return _("az topics: play, rules, cancel, manage, check") + end + +end + +plugin = AzGamePlugin.new +plugin.map 'az [:lang] word :cmd *params', :action=>'wordlist', :defaults => { :lang => nil, :cmd => 'count', :params => [] }, :auth_path => '!az::edit!' +plugin.map 'az cancel', :action=>'stop_game', :private => false +plugin.map 'az check :word', :action => 'manual_word_check', :private => false +plugin.map 'az [play] [:lang]', :action=>'start_game', :private => false, :defaults => { :lang => nil } + diff --git a/data/rbot/plugins/hl2.rb b/data/rbot/plugins/hl2.rb index d6444143..5023e01c 100644 --- a/data/rbot/plugins/hl2.rb +++ b/data/rbot/plugins/hl2.rb @@ -1,59 +1,59 @@ -#-- vim:sw=2:et
-#++
-#
-# :title: Half-Life 2 plugin for rbot
-#
-# Author:: Ole Christian Rynning <oc@rynning.no>
-# Copyright:: (C) 2006 Ole Christian Rynning
-# License:: GPL v2
-#
-# Simple Half-Life 2 (Source Engine) plugin to query online
-# servers to see if its online and kicking and how many users.
-#
-# Added 2 seconds timeout to the response. And sockets are now
-# closing properly.
-
-require 'socket'
-require 'timeout'
-
-class HL2Plugin < Plugin
-
- A2S_INFO = "\xFF\xFF\xFF\xFF\x54\x53\x6F\x75\x72\x63\x65\x20\x45\x6E\x67\x69\x6E\x65\x20\x51\x75\x65\x72\x79\x00"
-
- TIMEOUT = 2
-
- def a2s_info(addr, port)
- socket = UDPSocket.new()
- socket.send(A2S_INFO, 0, addr, port.to_i)
- response = nil
-
- begin
- timeout(TIMEOUT) do
- response = socket.recvfrom(1400,0)
- end
- rescue Exception
- end
-
- socket.close()
- response ? response.first.unpack("iACZ*Z*Z*Z*sCCCaaCCZ*") : nil
- end
-
- def help(plugin, topic="")
- "hl2 'server:port' => show basic information about the given server"
- end
-
- def hl2(m, params)
- addr, port = params[:conn_str].split(':')
- info = a2s_info(addr, port)
- if info != nil
- m.reply "#{info[3]} is online with #{info[8]}/#{info[9]} players."
- else
- m.reply "Couldn't connect to #{params[:conn_str]}"
- end
- end
-
-end
-
-plugin = HL2Plugin.new
-plugin.map 'hl2 :conn_str', :thread => true
-
+#-- vim:sw=2:et +#++ +# +# :title: Half-Life 2 plugin for rbot +# +# Author:: Ole Christian Rynning <oc@rynning.no> +# Copyright:: (C) 2006 Ole Christian Rynning +# License:: GPL v2 +# +# Simple Half-Life 2 (Source Engine) plugin to query online +# servers to see if its online and kicking and how many users. +# +# Added 2 seconds timeout to the response. And sockets are now +# closing properly. + +require 'socket' +require 'timeout' + +class HL2Plugin < Plugin + + A2S_INFO = "\xFF\xFF\xFF\xFF\x54\x53\x6F\x75\x72\x63\x65\x20\x45\x6E\x67\x69\x6E\x65\x20\x51\x75\x65\x72\x79\x00" + + TIMEOUT = 2 + + def a2s_info(addr, port) + socket = UDPSocket.new() + socket.send(A2S_INFO, 0, addr, port.to_i) + response = nil + + begin + timeout(TIMEOUT) do + response = socket.recvfrom(1400,0) + end + rescue Exception + end + + socket.close() + response ? response.first.unpack("iACZ*Z*Z*Z*sCCCaaCCZ*") : nil + end + + def help(plugin, topic="") + "hl2 'server:port' => show basic information about the given server" + end + + def hl2(m, params) + addr, port = params[:conn_str].split(':') + info = a2s_info(addr, port) + if info != nil + m.reply "#{info[3]} is online with #{info[8]}/#{info[9]} players." + else + m.reply "Couldn't connect to #{params[:conn_str]}" + end + end + +end + +plugin = HL2Plugin.new +plugin.map 'hl2 :conn_str', :thread => true + diff --git a/data/rbot/plugins/rss.rb b/data/rbot/plugins/rss.rb index f6e0b98a..3b8e8c7d 100644 --- a/data/rbot/plugins/rss.rb +++ b/data/rbot/plugins/rss.rb @@ -1,1107 +1,1107 @@ -#-- vim:sw=2:et
-#++
-#
-# :title: RSS feed plugin for rbot
-#
-# Author:: Stanislav Karchebny <berkus@madfire.net>
-# Author:: Ian Monroe <ian@monroe.nu>
-# Author:: Mark Kretschmann <markey@web.de>
-# Author:: Giuseppe Bilotta <giuseppe.bilotta@gmail.com>
-#
-# Copyright:: (C) 2004 Stanislav Karchebny
-# Copyright:: (C) 2005 Ian Monroe, Mark Kretschmann
-# Copyright:: (C) 2006-2007 Giuseppe Bilotta
-#
-# License:: MIT license
-
-require 'rss'
-
-# Try to load rss/content/2.0 so we can access the data in <content:encoded>
-# tags.
-begin
- require 'rss/content/2.0'
-rescue LoadError
-end
-
-module ::RSS
-
- # Make an 'unique' ID for a given item, based on appropriate bot options
- # Currently only suppored is bot.config['rss.show_updated']: when true, the
- # description is included in the uid hashing, otherwise it's not
- #
- def RSS.item_uid_for_bot(item, opts={})
- options = { :show_updated => true}.merge(opts)
- desc = nil
- if options[:show_updated]
- desc = item.content.content rescue item.description rescue nil
- end
- [(item.title.content rescue item.title rescue nil),
- (item.link.href rescue item.link),
- desc].hash
- end
-
- # Add support for Slashdot namespace in RDF. The code is just an adaptation
- # of the DublinCore code.
- unless defined?(SLASH_PREFIX)
- SLASH_PREFIX = 'slash'
- SLASH_URI = "http://purl.org/rss/1.0/modules/slash/"
-
- RDF.install_ns(SLASH_PREFIX, SLASH_URI)
-
- module BaseSlashModel
- def append_features(klass)
- super
-
- return if klass.instance_of?(Module)
- SlashModel::ELEMENT_NAME_INFOS.each do |name, plural_name|
- plural = plural_name || "#{name}s"
- full_name = "#{SLASH_PREFIX}_#{name}"
- full_plural_name = "#{SLASH_PREFIX}_#{plural}"
- klass_name = "Slash#{Utils.to_class_name(name)}"
-
- # This will fail with older version of the Ruby RSS module
- begin
- klass.install_have_children_element(name, SLASH_URI, "*",
- full_name, full_plural_name)
- klass.install_must_call_validator(SLASH_PREFIX, SLASH_URI)
- rescue ArgumentError
- klass.module_eval("install_have_children_element(#{full_name.dump}, #{full_plural_name.dump})")
- end
-
- klass.module_eval(<<-EOC, *get_file_and_line_from_caller(0))
- remove_method :#{full_name} if method_defined? :#{full_name}
- remove_method :#{full_name}= if method_defined? :#{full_name}=
- remove_method :set_#{full_name} if method_defined? :set_#{full_name}
-
- def #{full_name}
- @#{full_name}.first and @#{full_name}.first.value
- end
-
- def #{full_name}=(new_value)
- @#{full_name}[0] = Utils.new_with_value_if_need(#{klass_name}, new_value)
- end
- alias set_#{full_name} #{full_name}=
- EOC
- end
- end
- end
-
- module SlashModel
- extend BaseModel
- extend BaseSlashModel
-
- TEXT_ELEMENTS = {
- "department" => nil,
- "section" => nil,
- "comments" => nil,
- "hit_parade" => nil
- }
-
- ELEMENT_NAME_INFOS = SlashModel::TEXT_ELEMENTS.to_a
-
- ELEMENTS = TEXT_ELEMENTS.keys
-
- ELEMENTS.each do |name, plural_name|
- module_eval(<<-EOC, *get_file_and_line_from_caller(0))
- class Slash#{Utils.to_class_name(name)} < Element
- include RSS10
-
- content_setup
-
- class << self
- def required_prefix
- SLASH_PREFIX
- end
-
- def required_uri
- SLASH_URI
- end
- end
-
- @tag_name = #{name.dump}
-
- alias_method(:value, :content)
- alias_method(:value=, :content=)
-
- def initialize(*args)
- begin
- if Utils.element_initialize_arguments?(args)
- super
- else
- super()
- self.content = args[0]
- end
- # Older Ruby RSS module
- rescue NoMethodError
- super()
- self.content = args[0]
- end
- end
-
- def full_name
- tag_name_with_prefix(SLASH_PREFIX)
- end
-
- def maker_target(target)
- target.new_#{name}
- end
-
- def setup_maker_attributes(#{name})
- #{name}.content = content
- end
- end
- EOC
- end
- end
-
- class RDF
- class Item; include SlashModel; end
- end
-
- SlashModel::ELEMENTS.each do |name|
- class_name = Utils.to_class_name(name)
- BaseListener.install_class_name(SLASH_URI, name, "Slash#{class_name}")
- end
-
- SlashModel::ELEMENTS.collect! {|name| "#{SLASH_PREFIX}_#{name}"}
- end
-end
-
-
-class ::RssBlob
- attr_accessor :url, :handle, :type, :refresh_rate, :xml, :title, :items,
- :mutex, :watchers, :last_fetched
-
- def initialize(url,handle=nil,type=nil,watchers=[], xml=nil, lf = nil)
- @url = url
- if handle
- @handle = handle
- else
- @handle = url
- end
- @type = type
- @watchers=[]
- @refresh_rate = nil
- @xml = xml
- @title = nil
- @items = nil
- @mutex = Mutex.new
- @last_fetched = lf
- sanitize_watchers(watchers)
- end
-
- def dup
- @mutex.synchronize do
- self.class.new(@url,
- @handle,
- @type ? @type.dup : nil,
- @watchers.dup,
- @xml ? @xml.dup : nil,
- @last_fetched)
- end
- end
-
- # Downcase all watchers, possibly turning them into Strings if they weren't
- def sanitize_watchers(list=@watchers)
- ls = list.dup
- @watchers.clear
- ls.each { |w|
- add_watch(w)
- }
- end
-
- def watched?
- !@watchers.empty?
- end
-
- def watched_by?(who)
- @watchers.include?(who.downcase)
- end
-
- def add_watch(who)
- if watched_by?(who)
- return nil
- end
- @mutex.synchronize do
- @watchers << who.downcase
- end
- return who
- end
-
- def rm_watch(who)
- @mutex.synchronize do
- @watchers.delete(who.downcase)
- end
- end
-
- def to_a
- [@handle,@url,@type,@refresh_rate,@watchers]
- end
-
- def to_s(watchers=false)
- if watchers
- a = self.to_a.flatten
- else
- a = self.to_a[0,3]
- end
- a.compact.join(" | ")
- end
-end
-
-class RSSFeedsPlugin < Plugin
- Config.register Config::IntegerValue.new('rss.head_max',
- :default => 100, :validate => Proc.new{|v| v > 0 && v < 200},
- :desc => "How many characters to use of a RSS item header")
-
- Config.register Config::IntegerValue.new('rss.text_max',
- :default => 200, :validate => Proc.new{|v| v > 0 && v < 400},
- :desc => "How many characters to use of a RSS item text")
-
- Config.register Config::IntegerValue.new('rss.thread_sleep',
- :default => 300, :validate => Proc.new{|v| v > 30},
- :desc => "How many seconds to sleep before checking RSS feeds again")
-
- Config.register Config::BooleanValue.new('rss.show_updated',
- :default => true,
- :desc => "Whether feed items for which the description was changed should be shown as new")
-
- Config.register Config::BooleanValue.new('rss.show_links',
- :default => true,
- :desc => "Whether to display links from the text of a feed item.")
-
- # We used to save the Mutex with the RssBlob, which was idiotic. And
- # since Mutexes dumped in one version might not be resotrable in another,
- # we need a few tricks to be able to restore data from other versions of Ruby
- #
- # When migrating 1.8.6 => 1.8.5, all we need to do is define an empty
- # #marshal_load() method for Mutex. For 1.8.5 => 1.8.6 we need something
- # dirtier, as seen later on in the initialization code.
- unless Mutex.new.respond_to?(:marshal_load)
- class ::Mutex
- def marshal_load(str)
- return
- end
- end
- end
-
- attr_reader :feeds
-
- def initialize
- super
- if @registry.has_key?(:feeds)
- # When migrating from Ruby 1.8.5 to 1.8.6, dumped Mutexes may render the
- # data unrestorable. If this happens, we patch the data, thus allowing
- # the restore to work.
- #
- # This is actually pretty safe for a number of reasons:
- # * the code is only called if standard marshalling fails
- # * the string we look for is quite unlikely to appear randomly
- # * if the string appears somewhere and the patched string isn't recoverable
- # either, we'll get another (unrecoverable) error, which makes the rss
- # plugin unsable, just like it was if no recovery was attempted
- # * if the string appears somewhere and the patched string is recoverable,
- # we may get a b0rked feed, which is eventually overwritten by a clean
- # one, so the worst thing that can happen is that a feed update spams
- # the watchers once
- @registry.recovery = Proc.new { |val|
- patched = val.sub(":\v@mutexo:\nMutex", ":\v@mutexo:\vObject")
- ret = Marshal.restore(patched)
- ret.each_value { |blob|
- blob.mutex = nil
- blob
- }
- }
-
- @feeds = @registry[:feeds]
- raise unless @feeds
-
- @registry.recovery = nil
-
- @feeds.keys.grep(/[A-Z]/) { |k|
- @feeds[k.downcase] = @feeds[k]
- @feeds.delete(k)
- }
- @feeds.each { |k, f|
- f.mutex = Mutex.new
- f.sanitize_watchers
- parseRss(f) if f.xml
- }
- else
- @feeds = Hash.new
- end
- @watch = Hash.new
- rewatch_rss
- end
-
- def name
- "rss"
- end
-
- def watchlist
- @feeds.select { |h, f| f.watched? }
- end
-
- def cleanup
- stop_watches
- super
- end
-
- def save
- unparsed = Hash.new()
- @feeds.each { |k, f|
- unparsed[k] = f.dup
- # we don't want to save the mutex
- unparsed[k].mutex = nil
- }
- @registry[:feeds] = unparsed
- end
-
- def stop_watch(handle)
- if @watch.has_key?(handle)
- begin
- debug "Stopping watch #{handle}"
- @bot.timer.remove(@watch[handle])
- @watch.delete(handle)
- rescue Exception => e
- report_problem("Failed to stop watch for #{handle}", e, nil)
- end
- end
- end
-
- def stop_watches
- @watch.each_key { |k|
- stop_watch(k)
- }
- end
-
- def help(plugin,topic="")
- case topic
- when "show"
- "rss show #{Bold}handle#{Bold} [#{Bold}limit#{Bold}] : show #{Bold}limit#{Bold} (default: 5, max: 15) entries from rss #{Bold}handle#{Bold}; #{Bold}limit#{Bold} can also be in the form a..b, to display a specific range of items"
- when "list"
- "rss list [#{Bold}handle#{Bold}] : list all rss feeds (matching #{Bold}handle#{Bold})"
- when "watched"
- "rss watched [#{Bold}handle#{Bold}] [in #{Bold}chan#{Bold}]: list all watched rss feeds (matching #{Bold}handle#{Bold}) (in channel #{Bold}chan#{Bold})"
- when "who", "watches", "who watches"
- "rss who watches [#{Bold}handle#{Bold}]]: list all watchers for rss feeds (matching #{Bold}handle#{Bold})"
- when "add"
- "rss add #{Bold}handle#{Bold} #{Bold}url#{Bold} [#{Bold}type#{Bold}] : add a new rss called #{Bold}handle#{Bold} from url #{Bold}url#{Bold} (of type #{Bold}type#{Bold})"
- when "change"
- "rss change #{Bold}what#{Bold} of #{Bold}handle#{Bold} to #{Bold}new#{Bold} : change the #{Underline}handle#{Underline}, #{Underline}url#{Underline}, #{Underline}type#{Underline} or #{Underline}refresh#{Underline} rate of rss called #{Bold}handle#{Bold} to value #{Bold}new#{Bold}"
- when /^(del(ete)?|rm)$/
- "rss del(ete)|rm #{Bold}handle#{Bold} : delete rss feed #{Bold}handle#{Bold}"
- when "replace"
- "rss replace #{Bold}handle#{Bold} #{Bold}url#{Bold} [#{Bold}type#{Bold}] : try to replace the url of rss called #{Bold}handle#{Bold} with #{Bold}url#{Bold} (of type #{Bold}type#{Bold}); only works if nobody else is watching it"
- when "forcereplace"
- "rss forcereplace #{Bold}handle#{Bold} #{Bold}url#{Bold} [#{Bold}type#{Bold}] : replace the url of rss called #{Bold}handle#{Bold} with #{Bold}url#{Bold} (of type #{Bold}type#{Bold})"
- when "watch"
- "rss watch #{Bold}handle#{Bold} [#{Bold}url#{Bold} [#{Bold}type#{Bold}]] [in #{Bold}chan#{Bold}]: watch rss #{Bold}handle#{Bold} for changes (in channel #{Bold}chan#{Bold}); when the other parameters are present, the feed will be created if it doesn't exist yet"
- when /(un|rm)watch/
- "rss unwatch|rmwatch #{Bold}handle#{Bold} [in #{Bold}chan#{Bold}]: stop watching rss #{Bold}handle#{Bold} (in channel #{Bold}chan#{Bold}) for changes"
- when /who(?: watche?s?)?/
- "rss who watches #{Bold}handle#{Bold}: lists watches for rss #{Bold}handle#{Bold}"
- when "rewatch"
- "rss rewatch : restart threads that watch for changes in watched rss"
- else
- "manage RSS feeds: rss show|list|watched|add|change|del(ete)|rm|(force)replace|watch|unwatch|rmwatch|rewatch|who watches"
- end
- end
-
- def report_problem(report, e=nil, m=nil)
- if m && m.respond_to?(:reply)
- m.reply report
- else
- warning report
- end
- if e
- debug e.inspect
- debug e.backtrace.join("\n") if e.respond_to?(:backtrace)
- end
- end
-
- def show_rss(m, params)
- handle = params[:handle]
- lims = params[:limit].to_s.match(/(\d+)(?:..(\d+))?/)
- debug lims.to_a.inspect
- if lims[2]
- ll = [[lims[1].to_i-1,lims[2].to_i-1].min, 0].max
- ul = [[lims[1].to_i-1,lims[2].to_i-1].max, 14].min
- rev = lims[1].to_i > lims[2].to_i
- else
- ll = 0
- ul = [[lims[1].to_i-1, 0].max, 14].min
- rev = false
- end
-
- feed = @feeds.fetch(handle.downcase, nil)
- unless feed
- m.reply "I don't know any feeds named #{handle}"
- return
- end
-
- m.reply "lemme fetch it..."
- title = items = nil
- we_were_watching = false
-
- if @watch.key?(feed.handle)
- # If a feed is being watched, we run the watcher thread
- # so that all watchers can be informed of changes to
- # the feed. Before we do that, though, we remove the
- # show requester from the watchlist, if present, lest
- # he gets the update twice.
- if feed.watched_by?(m.replyto)
- we_were_watching = true
- feed.rm_watch(m.replyto)
- end
- @bot.timer.reschedule(@watch[feed.handle], 0)
- if we_were_watching
- feed.add_watch(m.replyto)
- end
- else
- fetched = fetchRss(feed, m, false)
- end
- return unless fetched or feed.xml
- if not fetched and feed.items
- m.reply "using old data"
- else
- parsed = parseRss(feed, m)
- m.reply "using old data" unless parsed
- end
- return unless feed.items
- title = feed.title
- items = feed.items
-
- # We sort the feeds in freshness order (newer ones first)
- items = freshness_sort(items)
- disp = items[ll..ul]
- disp.reverse! if rev
-
- m.reply "Channel : #{title}"
- disp.each do |item|
- printFormattedRss(feed, item, {:places=>[m.replyto],:handle=>nil,:date=>true})
- end
- end
-
- def itemDate(item,ex=nil)
- return item.pubDate if item.respond_to?(:pubDate) and item.pubDate
- return item.date if item.respond_to?(:date) and item.date
- return ex
- end
-
- def freshness_sort(items)
- notime = Time.at(0)
- items.sort { |a, b|
- itemDate(b, notime) <=> itemDate(a, notime)
- }
- end
-
- def list_rss(m, params)
- wanted = params[:handle]
- reply = String.new
- @feeds.each { |handle, feed|
- next if wanted and !handle.match(/#{wanted}/i)
- reply << "#{feed.handle}: #{feed.url} (in format: #{feed.type ? feed.type : 'default'})"
- (reply << " refreshing every #{Utils.secs_to_string(feed.refresh_rate)}") if feed.refresh_rate
- (reply << " (watched)") if feed.watched_by?(m.replyto)
- reply << "\n"
- }
- if reply.empty?
- reply = "no feeds found"
- reply << " matching #{wanted}" if wanted
- end
- m.reply reply, :max_lines => reply.length
- end
-
- def watched_rss(m, params)
- wanted = params[:handle]
- chan = params[:chan] || m.replyto
- reply = String.new
- watchlist.each { |handle, feed|
- next if wanted and !handle.match(/#{wanted}/i)
- next unless feed.watched_by?(chan)
- reply << "#{feed.handle}: #{feed.url} (in format: #{feed.type ? feed.type : 'default'})"
- (reply << " refreshing every #{Utils.secs_to_string(feed.refresh_rate)}") if feed.refresh_rate
- reply << "\n"
- }
- if reply.empty?
- reply = "no watched feeds"
- reply << " matching #{wanted}" if wanted
- end
- m.reply reply
- end
-
- def who_watches(m, params)
- wanted = params[:handle]
- reply = String.new
- watchlist.each { |handle, feed|
- next if wanted and !handle.match(/#{wanted}/i)
- reply << "#{feed.handle}: #{feed.url} (in format: #{feed.type ? feed.type : 'default'})"
- (reply << " refreshing every #{Utils.secs_to_string(feed.refresh_rate)}") if feed.refresh_rate
- reply << ": watched by #{feed.watchers.join(', ')}"
- reply << "\n"
- }
- if reply.empty?
- reply = "no watched feeds"
- reply << " matching #{wanted}" if wanted
- end
- m.reply reply
- end
-
- def add_rss(m, params, force=false)
- handle = params[:handle]
- url = params[:url]
- unless url.match(/https?/)
- m.reply "I only deal with feeds from HTTP sources, so I can't use #{url} (maybe you forgot the handle?)"
- return
- end
- type = params[:type]
- if @feeds.fetch(handle.downcase, nil) && !force
- m.reply "There is already a feed named #{handle} (URL: #{@feeds[handle.downcase].url})"
- return
- end
- unless url
- m.reply "You must specify both a handle and an url to add an RSS feed"
- return
- end
- @feeds[handle.downcase] = RssBlob.new(url,handle,type)
- reply = "Added RSS #{url} named #{handle}"
- if type
- reply << " (format: #{type})"
- end
- m.reply reply
- return handle
- end
-
- def change_rss(m, params)
- handle = params[:handle].downcase
- feed = @feeds.fetch(handle, nil)
- unless feed
- m.reply "No such feed with handle #{handle}"
- return
- end
- case params[:what].intern
- when :handle
- new = params[:new].downcase
- if @feeds.key?(new) and @feeds[new]
- m.reply "There already is a feed with handle #{new}"
- return
- else
- feed.mutex.synchronize do
- @feeds[new] = feed
- @feeds.delete(handle)
- feed.handle = new
- end
- handle = new
- end
- when :url
- new = params[:new]
- feed.mutex.synchronize do
- feed.url = new
- end
- when :format, :type
- new = params[:new]
- new = nil if new == 'default'
- feed.mutex.synchronize do
- feed.type = new
- end
- when :refresh
- new = params[:new].to_i
- new = nil if new == 0
- feed.mutex.synchronize do
- feed.refresh_rate = new
- end
- else
- m.reply "Don't know how to change #{params[:what]} for feeds"
- return
- end
- m.reply "Feed changed:"
- list_rss(m, {:handle => handle})
- end
-
- def del_rss(m, params, pass=false)
- feed = unwatch_rss(m, params, true)
- return unless feed
- if feed.watched?
- m.reply "someone else is watching #{feed.handle}, I won't remove it from my list"
- return
- end
- @feeds.delete(feed.handle.downcase)
- m.okay unless pass
- return
- end
-
- def replace_rss(m, params)
- handle = params[:handle]
- if @feeds.key?(handle.downcase)
- del_rss(m, {:handle => handle}, true)
- end
- if @feeds.key?(handle.downcase)
- m.reply "can't replace #{feed.handle}"
- else
- add_rss(m, params, true)
- end
- end
-
- def forcereplace_rss(m, params)
- add_rss(m, params, true)
- end
-
- def watch_rss(m, params)
- handle = params[:handle]
- chan = params[:chan] || m.replyto
- url = params[:url]
- type = params[:type]
- if url
- add_rss(m, params)
- end
- feed = @feeds.fetch(handle.downcase, nil)
- if feed
- if feed.add_watch(chan)
- watchRss(feed, m)
- m.okay
- else
- m.reply "Already watching #{feed.handle} in #{chan}"
- end
- else
- m.reply "Couldn't watch feed #{handle} (no such feed found)"
- end
- end
-
- def unwatch_rss(m, params, pass=false)
- handle = params[:handle].downcase
- chan = params[:chan] || m.replyto
- unless @feeds.has_key?(handle)
- m.reply("dunno that feed")
- return
- end
- feed = @feeds[handle]
- if feed.rm_watch(chan)
- m.reply "#{chan} has been removed from the watchlist for #{feed.handle}"
- else
- m.reply("#{chan} wasn't watching #{feed.handle}") unless pass
- end
- if !feed.watched?
- stop_watch(handle)
- end
- return feed
- end
-
- def rewatch_rss(m=nil, params=nil)
- if params and handle = params[:handle]
- feed = @feeds.fetch(handle.downcase, nil)
- if feed
- @bot.timer.reschedule(@watch[feed.handle], 0)
- m.okay if m
- else
- m.reply _("no such feed %{handle}") % { :handle => handle } if m
- end
- else
- stop_watches
-
- # Read watches from list.
- watchlist.each{ |handle, feed|
- watchRss(feed, m)
- }
- m.okay if m
- end
- end
-
- private
- def watchRss(feed, m=nil)
- if @watch.has_key?(feed.handle)
- report_problem("watcher thread for #{feed.handle} is already running", nil, m)
- return
- end
- status = Hash.new
- status[:failures] = 0
- tmout = 0
- if feed.last_fetched
- tmout = feed.last_fetched + calculate_timeout(feed) - Time.now
- tmout = 0 if tmout < 0
- end
- debug "scheduling a watcher for #{feed} in #{tmout} seconds"
- @watch[feed.handle] = @bot.timer.add(tmout) {
- debug "watcher for #{feed} wakes up"
- failures = status[:failures]
- begin
- debug "fetching #{feed}"
- first_run = !feed.last_fetched
- oldxml = feed.xml ? feed.xml.dup : nil
- unless fetchRss(feed)
- failures += 1
- else
- if first_run
- debug "first run for #{feed}, getting items"
- parseRss(feed)
- elsif oldxml and oldxml == feed.xml
- debug "xml for #{feed} didn't change"
- failures -= 1 if failures > 0
- else
- if not feed.items
- debug "no previous items in feed #{feed}"
- parseRss(feed)
- failures -= 1 if failures > 0
- else
- # This one is used for debugging
- otxt = []
-
- # These are used for checking new items vs old ones
- uid_opts = { :show_updated => @bot.config['rss.show_updated'] }
- oids = Set.new feed.items.map { |item|
- uid = RSS.item_uid_for_bot(item, uid_opts)
- otxt << item.to_s
- debug [uid, item].inspect
- debug [uid, otxt.last].inspect
- uid
- }
-
- unless parseRss(feed)
- debug "no items in feed #{feed}"
- failures += 1
- else
- debug "Checking if new items are available for #{feed}"
- failures -= 1 if failures > 0
- # debug "Old:"
- # debug oldxml
- # debug "New:"
- # debug feed.xml
-
- dispItems = feed.items.reject { |item|
- uid = RSS.item_uid_for_bot(item, uid_opts)
- txt = item.to_s
- if oids.include?(uid)
- debug "rejecting old #{uid} #{item.inspect}"
- debug [uid, txt].inspect
- true
- else
- debug "accepting new #{uid} #{item.inspect}"
- debug [uid, txt].inspect
- warning "same text! #{txt}" if otxt.include?(txt)
- false
- end
- }
-
- if dispItems.length > 0
- debug "Found #{dispItems.length} new items in #{feed}"
- # When displaying watched feeds, publish them from older to newer
- dispItems.reverse.each { |item|
- printFormattedRss(feed, item)
- }
- else
- debug "No new items found in #{feed}"
- end
- end
- end
- end
- end
- rescue Exception => e
- error "Error watching #{feed}: #{e.inspect}"
- debug e.backtrace.join("\n")
- failures += 1
- end
-
- status[:failures] = failures
-
- seconds = calculate_timeout(feed, failures)
- debug "watcher for #{feed} going to sleep #{seconds} seconds.."
- begin
- @bot.timer.reschedule(@watch[feed.handle], seconds)
- rescue
- warning "watcher for #{feed} failed to reschedule: #{$!.inspect}"
- end
- }
- debug "watcher for #{feed} added"
- end
-
- def calculate_timeout(feed, failures = 0)
- seconds = @bot.config['rss.thread_sleep']
- feed.mutex.synchronize do
- seconds = feed.refresh_rate if feed.refresh_rate
- end
- seconds *= failures + 1
- seconds += seconds * (rand(100)-50)/100
- return seconds
- end
-
- def select_nonempty(*ar)
- debug ar
- ret = ar.map { |i| (i && i.empty?) ? nil : i }.compact.first
- (ret && ret.empty?) ? nil : ret
- end
-
- def printFormattedRss(feed, item, opts=nil)
- debug item
- places = feed.watchers
- handle = "::#{feed.handle}:: "
- date = String.new
- if opts
- places = opts[:places] if opts.key?(:places)
- handle = opts[:handle].to_s if opts.key?(:handle)
- if opts.key?(:date) && opts[:date]
- if item.respond_to?(:updated)
- if item.updated.content.class <= Time
- date = item.updated.content.strftime("%Y/%m/%d %H:%M")
- else
- date = item.updated.content.to_s
- end
- elsif item.respond_to?(:source) and item.source.respond_to?(:updated)
- if item.source.updated.content.class <= Time
- date = item.source.updated.content.strftime("%Y/%m/%d %H:%M")
- else
- date = item.source.updated.content.to_s
- end
- elsif item.respond_to?(:pubDate)
- if item.pubDate.class <= Time
- date = item.pubDate.strftime("%Y/%m/%d %H:%M")
- else
- date = item.pubDate.to_s
- end
- elsif item.respond_to?(:date)
- if item.date.class <= Time
- date = item.date.strftime("%Y/%m/%d %H:%M")
- else
- date = item.date.to_s
- end
- else
- date = "(no date)"
- end
- date += " :: "
- end
- end
-
- tit_opt = {}
- # Twitters don't need a cap on the title length since they have a hard
- # limit to 160 characters, and most of them are under 140 characters
- tit_opt[:limit] = @bot.config['rss.head_max'] unless feed.type == 'twitter'
-
- if item.title
- base_title = item.title.to_s.dup
- # git changesets are SHA1 hashes (40 hex digits), way too long, get rid of them, as they are
- # visible in the URL anyway
- # TODO make this optional?
- base_title.sub!(/^Changeset \[([\da-f]{40})\]:/) { |c| "(git commit)"} if feed.type == 'trac'
- title = "#{Bold}#{base_title.ircify_html(tit_opt)}#{Bold}"
- end
-
- desc_opt = {}
- desc_opt[:limit] = @bot.config['rss.text_max']
- desc_opt[:a_href] = :link_out if @bot.config['rss.show_links']
-
- # We prefer content_encoded here as it tends to provide more html formatting
- # for use with ircify_html.
- if item.respond_to?(:content_encoded) && item.content_encoded
- desc = item.content_encoded.ircify_html(desc_opt)
- elsif item.respond_to?(:description) && item.description
- desc = item.description.ircify_html(desc_opt)
- else
- if item.content.type == "html"
- desc = item.content.content.ircify_html(desc_opt)
- else
- desc = item.content.content
- if desc.size > desc_opt[:limit]
- desc = desc.slice(0, desc_opt[:limit]) + "#{Reverse}...#{Reverse}"
- end
- end
- end
-
- link = item.link.href rescue item.link.chomp rescue nil
-
- category = select_nonempty((item.category.content rescue nil), (item.dc_subject rescue nil))
- author = select_nonempty((item.author.name.content rescue nil), (item.dc_creator rescue nil), (item.author rescue nil))
-
- line1 = nil
- line2 = nil
-
- at = ((item.title && item.link) ? ' @ ' : '')
-
- case feed.type
- when 'blog'
- author += " " if author
- abt = category ? "about #{category} " : ""
- line1 = "#{handle}#{date}#{author}blogged #{abt}at #{link}"
- line2 = "#{handle}#{title} - #{desc}"
- when 'photoblog'
- author += " " if author
- abt = category ? "under #{category} " : ""
- line1 = "#{handle}#{date}#{author}added an image #{abt}at #{link}"
- line2 = "#{handle}#{title} - #{desc}"
- when 'news'
- line1 = "#{handle}#{date}#{title} @ #{link}"
- line2 = line2 = "#{handle}#{date}#{desc}"
- when 'git'
- author += " " if author
- line1 = "#{handle}#{date}#{author}commited #{title} @ #{link}"
- when 'forum'
- line1 = "#{handle}#{date}#{title}#{at}#{link}"
- when 'wiki'
- line1 = "#{handle}#{date}#{title}#{at}#{link} has been edited by #{author}. #{desc}"
- when 'gmane'
- line1 = "#{handle}#{date}Message #{title} sent by #{author}. #{desc}"
- when 'trac'
- line1 = "#{handle}#{date}#{title} @ #{link}"
- unless item.title =~ /^(?:Changeset \[(?:[\da-f]+)\]|\(git commit\))/
- line2 = "#{handle}#{date}#{desc}"
- end
- when '/.'
- dept = "(from the #{item.slash_department} dept) " rescue nil
- sec = " in section #{item.slash_section}" rescue nil
-
- line1 = "#{handle}#{date}#{dept}#{title}#{at}#{link} (posted by #{author}#{sec})"
- else
- line1 = "#{handle}#{date}#{title}#{at}#{link}"
- line1 << " (by #{author})" if author
- end
- places.each { |loc|
- @bot.say loc, line1, :overlong => :truncate
- next unless line2
- @bot.say loc, line2, :overlong => :truncate
- }
- end
-
- def fetchRss(feed, m=nil, cache=true)
- feed.last_fetched = Time.now
- begin
- # Use 60 sec timeout, cause the default is too low
- xml = @bot.httputil.get(feed.url,
- :read_timeout => 60,
- :open_timeout => 60,
- :cache => cache)
- rescue URI::InvalidURIError, URI::BadURIError => e
- report_problem("invalid rss feed #{feed.url}", e, m)
- return nil
- rescue => e
- report_problem("error getting #{feed.url}", e, m)
- return nil
- end
- debug "fetched #{feed}"
- unless xml
- report_problem("reading feed #{feed} failed", nil, m)
- return nil
- end
- # Ok, 0.9 feeds are not supported, maybe because
- # Netscape happily removed the DTD. So what we do is just to
- # reassign the 0.9 RDFs to 1.0, and hope it goes right.
- xml.gsub!("xmlns=\"http://my.netscape.com/rdf/simple/0.9/\"",
- "xmlns=\"http://purl.org/rss/1.0/\"")
- feed.mutex.synchronize do
- feed.xml = xml
- end
- return true
- end
-
- def parseRss(feed, m=nil)
- return nil unless feed.xml
- feed.mutex.synchronize do
- xml = feed.xml
- begin
- ## do validate parse
- rss = RSS::Parser.parse(xml)
- debug "parsed and validated #{feed}"
- rescue RSS::InvalidRSSError
- ## do non validate parse for invalid RSS 1.0
- begin
- rss = RSS::Parser.parse(xml, false)
- debug "parsed but not validated #{feed}"
- rescue RSS::Error => e
- report_problem("parsing rss stream failed, whoops =(", e, m)
- return nil
- end
- rescue RSS::Error => e
- report_problem("parsing rss stream failed, oioi", e, m)
- return nil
- rescue => e
- report_problem("processing error occured, sorry =(", e, m)
- return nil
- end
- items = []
- if rss.nil?
- report_problem("#{feed} does not include RSS 1.0 or 0.9x/2.0", nil, m)
- else
- begin
- rss.output_encoding = 'UTF-8'
- rescue RSS::UnknownConvertMethod => e
- report_problem("bah! something went wrong =(", e, m)
- return nil
- end
- if rss.respond_to? :channel
- rss.channel.title ||= "Unknown"
- title = rss.channel.title
- else
- title = rss.title.content
- end
- rss.items.each do |item|
- item.title ||= "Unknown"
- items << item
- end
- end
-
- if items.empty?
- report_problem("no items found in the feed, maybe try weed?", e, m)
- return nil
- end
- feed.title = title
- feed.items = items
- return true
- end
- end
-end
-
-plugin = RSSFeedsPlugin.new
-
-plugin.default_auth( 'edit', false )
-plugin.default_auth( 'edit:add', true)
-
-plugin.map 'rss show :handle :limit',
- :action => 'show_rss',
- :requirements => {:limit => /^\d+(?:\.\.\d+)?$/},
- :defaults => {:limit => 5}
-plugin.map 'rss list :handle',
- :action => 'list_rss',
- :defaults => {:handle => nil}
-plugin.map 'rss watched :handle [in :chan]',
- :action => 'watched_rss',
- :defaults => {:handle => nil}
-plugin.map 'rss who watches :handle',
- :action => 'who_watches',
- :defaults => {:handle => nil}
-plugin.map 'rss add :handle :url :type',
- :action => 'add_rss',
- :auth_path => 'edit',
- :defaults => {:type => nil}
-plugin.map 'rss change :what of :handle to :new',
- :action => 'change_rss',
- :auth_path => 'edit',
- :requirements => { :what => /handle|url|format|type|refresh/ }
-plugin.map 'rss change :what for :handle to :new',
- :action => 'change_rss',
- :auth_path => 'edit',
- :requirements => { :what => /handle|url|format|type|refesh/ }
-plugin.map 'rss del :handle',
- :auth_path => 'edit:rm!',
- :action => 'del_rss'
-plugin.map 'rss delete :handle',
- :auth_path => 'edit:rm!',
- :action => 'del_rss'
-plugin.map 'rss rm :handle',
- :auth_path => 'edit:rm!',
- :action => 'del_rss'
-plugin.map 'rss replace :handle :url :type',
- :auth_path => 'edit',
- :action => 'replace_rss',
- :defaults => {:type => nil}
-plugin.map 'rss forcereplace :handle :url :type',
- :auth_path => 'edit',
- :action => 'forcereplace_rss',
- :defaults => {:type => nil}
-plugin.map 'rss watch :handle [in :chan]',
- :action => 'watch_rss',
- :defaults => {:url => nil, :type => nil}
-plugin.map 'rss watch :handle :url :type [in :chan]',
- :action => 'watch_rss',
- :defaults => {:url => nil, :type => nil}
-plugin.map 'rss unwatch :handle [in :chan]',
- :action => 'unwatch_rss'
-plugin.map 'rss rmwatch :handle [in :chan]',
- :action => 'unwatch_rss'
-plugin.map 'rss rewatch [:handle]',
- :action => 'rewatch_rss'
+#-- vim:sw=2:et +#++ +# +# :title: RSS feed plugin for rbot +# +# Author:: Stanislav Karchebny <berkus@madfire.net> +# Author:: Ian Monroe <ian@monroe.nu> +# Author:: Mark Kretschmann <markey@web.de> +# Author:: Giuseppe Bilotta <giuseppe.bilotta@gmail.com> +# +# Copyright:: (C) 2004 Stanislav Karchebny +# Copyright:: (C) 2005 Ian Monroe, Mark Kretschmann +# Copyright:: (C) 2006-2007 Giuseppe Bilotta +# +# License:: MIT license + +require 'rss' + +# Try to load rss/content/2.0 so we can access the data in <content:encoded> +# tags. +begin + require 'rss/content/2.0' +rescue LoadError +end + +module ::RSS + + # Make an 'unique' ID for a given item, based on appropriate bot options + # Currently only suppored is bot.config['rss.show_updated']: when true, the + # description is included in the uid hashing, otherwise it's not + # + def RSS.item_uid_for_bot(item, opts={}) + options = { :show_updated => true}.merge(opts) + desc = nil + if options[:show_updated] + desc = item.content.content rescue item.description rescue nil + end + [(item.title.content rescue item.title rescue nil), + (item.link.href rescue item.link), + desc].hash + end + + # Add support for Slashdot namespace in RDF. The code is just an adaptation + # of the DublinCore code. + unless defined?(SLASH_PREFIX) + SLASH_PREFIX = 'slash' + SLASH_URI = "http://purl.org/rss/1.0/modules/slash/" + + RDF.install_ns(SLASH_PREFIX, SLASH_URI) + + module BaseSlashModel + def append_features(klass) + super + + return if klass.instance_of?(Module) + SlashModel::ELEMENT_NAME_INFOS.each do |name, plural_name| + plural = plural_name || "#{name}s" + full_name = "#{SLASH_PREFIX}_#{name}" + full_plural_name = "#{SLASH_PREFIX}_#{plural}" + klass_name = "Slash#{Utils.to_class_name(name)}" + + # This will fail with older version of the Ruby RSS module + begin + klass.install_have_children_element(name, SLASH_URI, "*", + full_name, full_plural_name) + klass.install_must_call_validator(SLASH_PREFIX, SLASH_URI) + rescue ArgumentError + klass.module_eval("install_have_children_element(#{full_name.dump}, #{full_plural_name.dump})") + end + + klass.module_eval(<<-EOC, *get_file_and_line_from_caller(0)) + remove_method :#{full_name} if method_defined? :#{full_name} + remove_method :#{full_name}= if method_defined? :#{full_name}= + remove_method :set_#{full_name} if method_defined? :set_#{full_name} + + def #{full_name} + @#{full_name}.first and @#{full_name}.first.value + end + + def #{full_name}=(new_value) + @#{full_name}[0] = Utils.new_with_value_if_need(#{klass_name}, new_value) + end + alias set_#{full_name} #{full_name}= + EOC + end + end + end + + module SlashModel + extend BaseModel + extend BaseSlashModel + + TEXT_ELEMENTS = { + "department" => nil, + "section" => nil, + "comments" => nil, + "hit_parade" => nil + } + + ELEMENT_NAME_INFOS = SlashModel::TEXT_ELEMENTS.to_a + + ELEMENTS = TEXT_ELEMENTS.keys + + ELEMENTS.each do |name, plural_name| + module_eval(<<-EOC, *get_file_and_line_from_caller(0)) + class Slash#{Utils.to_class_name(name)} < Element + include RSS10 + + content_setup + + class << self + def required_prefix + SLASH_PREFIX + end + + def required_uri + SLASH_URI + end + end + + @tag_name = #{name.dump} + + alias_method(:value, :content) + alias_method(:value=, :content=) + + def initialize(*args) + begin + if Utils.element_initialize_arguments?(args) + super + else + super() + self.content = args[0] + end + # Older Ruby RSS module + rescue NoMethodError + super() + self.content = args[0] + end + end + + def full_name + tag_name_with_prefix(SLASH_PREFIX) + end + + def maker_target(target) + target.new_#{name} + end + + def setup_maker_attributes(#{name}) + #{name}.content = content + end + end + EOC + end + end + + class RDF + class Item; include SlashModel; end + end + + SlashModel::ELEMENTS.each do |name| + class_name = Utils.to_class_name(name) + BaseListener.install_class_name(SLASH_URI, name, "Slash#{class_name}") + end + + SlashModel::ELEMENTS.collect! {|name| "#{SLASH_PREFIX}_#{name}"} + end +end + + +class ::RssBlob + attr_accessor :url, :handle, :type, :refresh_rate, :xml, :title, :items, + :mutex, :watchers, :last_fetched + + def initialize(url,handle=nil,type=nil,watchers=[], xml=nil, lf = nil) + @url = url + if handle + @handle = handle + else + @handle = url + end + @type = type + @watchers=[] + @refresh_rate = nil + @xml = xml + @title = nil + @items = nil + @mutex = Mutex.new + @last_fetched = lf + sanitize_watchers(watchers) + end + + def dup + @mutex.synchronize do + self.class.new(@url, + @handle, + @type ? @type.dup : nil, + @watchers.dup, + @xml ? @xml.dup : nil, + @last_fetched) + end + end + + # Downcase all watchers, possibly turning them into Strings if they weren't + def sanitize_watchers(list=@watchers) + ls = list.dup + @watchers.clear + ls.each { |w| + add_watch(w) + } + end + + def watched? + !@watchers.empty? + end + + def watched_by?(who) + @watchers.include?(who.downcase) + end + + def add_watch(who) + if watched_by?(who) + return nil + end + @mutex.synchronize do + @watchers << who.downcase + end + return who + end + + def rm_watch(who) + @mutex.synchronize do + @watchers.delete(who.downcase) + end + end + + def to_a + [@handle,@url,@type,@refresh_rate,@watchers] + end + + def to_s(watchers=false) + if watchers + a = self.to_a.flatten + else + a = self.to_a[0,3] + end + a.compact.join(" | ") + end +end + +class RSSFeedsPlugin < Plugin + Config.register Config::IntegerValue.new('rss.head_max', + :default => 100, :validate => Proc.new{|v| v > 0 && v < 200}, + :desc => "How many characters to use of a RSS item header") + + Config.register Config::IntegerValue.new('rss.text_max', + :default => 200, :validate => Proc.new{|v| v > 0 && v < 400}, + :desc => "How many characters to use of a RSS item text") + + Config.register Config::IntegerValue.new('rss.thread_sleep', + :default => 300, :validate => Proc.new{|v| v > 30}, + :desc => "How many seconds to sleep before checking RSS feeds again") + + Config.register Config::BooleanValue.new('rss.show_updated', + :default => true, + :desc => "Whether feed items for which the description was changed should be shown as new") + + Config.register Config::BooleanValue.new('rss.show_links', + :default => true, + :desc => "Whether to display links from the text of a feed item.") + + # We used to save the Mutex with the RssBlob, which was idiotic. And + # since Mutexes dumped in one version might not be resotrable in another, + # we need a few tricks to be able to restore data from other versions of Ruby + # + # When migrating 1.8.6 => 1.8.5, all we need to do is define an empty + # #marshal_load() method for Mutex. For 1.8.5 => 1.8.6 we need something + # dirtier, as seen later on in the initialization code. + unless Mutex.new.respond_to?(:marshal_load) + class ::Mutex + def marshal_load(str) + return + end + end + end + + attr_reader :feeds + + def initialize + super + if @registry.has_key?(:feeds) + # When migrating from Ruby 1.8.5 to 1.8.6, dumped Mutexes may render the + # data unrestorable. If this happens, we patch the data, thus allowing + # the restore to work. + # + # This is actually pretty safe for a number of reasons: + # * the code is only called if standard marshalling fails + # * the string we look for is quite unlikely to appear randomly + # * if the string appears somewhere and the patched string isn't recoverable + # either, we'll get another (unrecoverable) error, which makes the rss + # plugin unsable, just like it was if no recovery was attempted + # * if the string appears somewhere and the patched string is recoverable, + # we may get a b0rked feed, which is eventually overwritten by a clean + # one, so the worst thing that can happen is that a feed update spams + # the watchers once + @registry.recovery = Proc.new { |val| + patched = val.sub(":\v@mutexo:\nMutex", ":\v@mutexo:\vObject") + ret = Marshal.restore(patched) + ret.each_value { |blob| + blob.mutex = nil + blob + } + } + + @feeds = @registry[:feeds] + raise unless @feeds + + @registry.recovery = nil + + @feeds.keys.grep(/[A-Z]/) { |k| + @feeds[k.downcase] = @feeds[k] + @feeds.delete(k) + } + @feeds.each { |k, f| + f.mutex = Mutex.new + f.sanitize_watchers + parseRss(f) if f.xml + } + else + @feeds = Hash.new + end + @watch = Hash.new + rewatch_rss + end + + def name + "rss" + end + + def watchlist + @feeds.select { |h, f| f.watched? } + end + + def cleanup + stop_watches + super + end + + def save + unparsed = Hash.new() + @feeds.each { |k, f| + unparsed[k] = f.dup + # we don't want to save the mutex + unparsed[k].mutex = nil + } + @registry[:feeds] = unparsed + end + + def stop_watch(handle) + if @watch.has_key?(handle) + begin + debug "Stopping watch #{handle}" + @bot.timer.remove(@watch[handle]) + @watch.delete(handle) + rescue Exception => e + report_problem("Failed to stop watch for #{handle}", e, nil) + end + end + end + + def stop_watches + @watch.each_key { |k| + stop_watch(k) + } + end + + def help(plugin,topic="") + case topic + when "show" + "rss show #{Bold}handle#{Bold} [#{Bold}limit#{Bold}] : show #{Bold}limit#{Bold} (default: 5, max: 15) entries from rss #{Bold}handle#{Bold}; #{Bold}limit#{Bold} can also be in the form a..b, to display a specific range of items" + when "list" + "rss list [#{Bold}handle#{Bold}] : list all rss feeds (matching #{Bold}handle#{Bold})" + when "watched" + "rss watched [#{Bold}handle#{Bold}] [in #{Bold}chan#{Bold}]: list all watched rss feeds (matching #{Bold}handle#{Bold}) (in channel #{Bold}chan#{Bold})" + when "who", "watches", "who watches" + "rss who watches [#{Bold}handle#{Bold}]]: list all watchers for rss feeds (matching #{Bold}handle#{Bold})" + when "add" + "rss add #{Bold}handle#{Bold} #{Bold}url#{Bold} [#{Bold}type#{Bold}] : add a new rss called #{Bold}handle#{Bold} from url #{Bold}url#{Bold} (of type #{Bold}type#{Bold})" + when "change" + "rss change #{Bold}what#{Bold} of #{Bold}handle#{Bold} to #{Bold}new#{Bold} : change the #{Underline}handle#{Underline}, #{Underline}url#{Underline}, #{Underline}type#{Underline} or #{Underline}refresh#{Underline} rate of rss called #{Bold}handle#{Bold} to value #{Bold}new#{Bold}" + when /^(del(ete)?|rm)$/ + "rss del(ete)|rm #{Bold}handle#{Bold} : delete rss feed #{Bold}handle#{Bold}" + when "replace" + "rss replace #{Bold}handle#{Bold} #{Bold}url#{Bold} [#{Bold}type#{Bold}] : try to replace the url of rss called #{Bold}handle#{Bold} with #{Bold}url#{Bold} (of type #{Bold}type#{Bold}); only works if nobody else is watching it" + when "forcereplace" + "rss forcereplace #{Bold}handle#{Bold} #{Bold}url#{Bold} [#{Bold}type#{Bold}] : replace the url of rss called #{Bold}handle#{Bold} with #{Bold}url#{Bold} (of type #{Bold}type#{Bold})" + when "watch" + "rss watch #{Bold}handle#{Bold} [#{Bold}url#{Bold} [#{Bold}type#{Bold}]] [in #{Bold}chan#{Bold}]: watch rss #{Bold}handle#{Bold} for changes (in channel #{Bold}chan#{Bold}); when the other parameters are present, the feed will be created if it doesn't exist yet" + when /(un|rm)watch/ + "rss unwatch|rmwatch #{Bold}handle#{Bold} [in #{Bold}chan#{Bold}]: stop watching rss #{Bold}handle#{Bold} (in channel #{Bold}chan#{Bold}) for changes" + when /who(?: watche?s?)?/ + "rss who watches #{Bold}handle#{Bold}: lists watches for rss #{Bold}handle#{Bold}" + when "rewatch" + "rss rewatch : restart threads that watch for changes in watched rss" + else + "manage RSS feeds: rss show|list|watched|add|change|del(ete)|rm|(force)replace|watch|unwatch|rmwatch|rewatch|who watches" + end + end + + def report_problem(report, e=nil, m=nil) + if m && m.respond_to?(:reply) + m.reply report + else + warning report + end + if e + debug e.inspect + debug e.backtrace.join("\n") if e.respond_to?(:backtrace) + end + end + + def show_rss(m, params) + handle = params[:handle] + lims = params[:limit].to_s.match(/(\d+)(?:..(\d+))?/) + debug lims.to_a.inspect + if lims[2] + ll = [[lims[1].to_i-1,lims[2].to_i-1].min, 0].max + ul = [[lims[1].to_i-1,lims[2].to_i-1].max, 14].min + rev = lims[1].to_i > lims[2].to_i + else + ll = 0 + ul = [[lims[1].to_i-1, 0].max, 14].min + rev = false + end + + feed = @feeds.fetch(handle.downcase, nil) + unless feed + m.reply "I don't know any feeds named #{handle}" + return + end + + m.reply "lemme fetch it..." + title = items = nil + we_were_watching = false + + if @watch.key?(feed.handle) + # If a feed is being watched, we run the watcher thread + # so that all watchers can be informed of changes to + # the feed. Before we do that, though, we remove the + # show requester from the watchlist, if present, lest + # he gets the update twice. + if feed.watched_by?(m.replyto) + we_were_watching = true + feed.rm_watch(m.replyto) + end + @bot.timer.reschedule(@watch[feed.handle], 0) + if we_were_watching + feed.add_watch(m.replyto) + end + else + fetched = fetchRss(feed, m, false) + end + return unless fetched or feed.xml + if not fetched and feed.items + m.reply "using old data" + else + parsed = parseRss(feed, m) + m.reply "using old data" unless parsed + end + return unless feed.items + title = feed.title + items = feed.items + + # We sort the feeds in freshness order (newer ones first) + items = freshness_sort(items) + disp = items[ll..ul] + disp.reverse! if rev + + m.reply "Channel : #{title}" + disp.each do |item| + printFormattedRss(feed, item, {:places=>[m.replyto],:handle=>nil,:date=>true}) + end + end + + def itemDate(item,ex=nil) + return item.pubDate if item.respond_to?(:pubDate) and item.pubDate + return item.date if item.respond_to?(:date) and item.date + return ex + end + + def freshness_sort(items) + notime = Time.at(0) + items.sort { |a, b| + itemDate(b, notime) <=> itemDate(a, notime) + } + end + + def list_rss(m, params) + wanted = params[:handle] + reply = String.new + @feeds.each { |handle, feed| + next if wanted and !handle.match(/#{wanted}/i) + reply << "#{feed.handle}: #{feed.url} (in format: #{feed.type ? feed.type : 'default'})" + (reply << " refreshing every #{Utils.secs_to_string(feed.refresh_rate)}") if feed.refresh_rate + (reply << " (watched)") if feed.watched_by?(m.replyto) + reply << "\n" + } + if reply.empty? + reply = "no feeds found" + reply << " matching #{wanted}" if wanted + end + m.reply reply, :max_lines => reply.length + end + + def watched_rss(m, params) + wanted = params[:handle] + chan = params[:chan] || m.replyto + reply = String.new + watchlist.each { |handle, feed| + next if wanted and !handle.match(/#{wanted}/i) + next unless feed.watched_by?(chan) + reply << "#{feed.handle}: #{feed.url} (in format: #{feed.type ? feed.type : 'default'})" + (reply << " refreshing every #{Utils.secs_to_string(feed.refresh_rate)}") if feed.refresh_rate + reply << "\n" + } + if reply.empty? + reply = "no watched feeds" + reply << " matching #{wanted}" if wanted + end + m.reply reply + end + + def who_watches(m, params) + wanted = params[:handle] + reply = String.new + watchlist.each { |handle, feed| + next if wanted and !handle.match(/#{wanted}/i) + reply << "#{feed.handle}: #{feed.url} (in format: #{feed.type ? feed.type : 'default'})" + (reply << " refreshing every #{Utils.secs_to_string(feed.refresh_rate)}") if feed.refresh_rate + reply << ": watched by #{feed.watchers.join(', ')}" + reply << "\n" + } + if reply.empty? + reply = "no watched feeds" + reply << " matching #{wanted}" if wanted + end + m.reply reply + end + + def add_rss(m, params, force=false) + handle = params[:handle] + url = params[:url] + unless url.match(/https?/) + m.reply "I only deal with feeds from HTTP sources, so I can't use #{url} (maybe you forgot the handle?)" + return + end + type = params[:type] + if @feeds.fetch(handle.downcase, nil) && !force + m.reply "There is already a feed named #{handle} (URL: #{@feeds[handle.downcase].url})" + return + end + unless url + m.reply "You must specify both a handle and an url to add an RSS feed" + return + end + @feeds[handle.downcase] = RssBlob.new(url,handle,type) + reply = "Added RSS #{url} named #{handle}" + if type + reply << " (format: #{type})" + end + m.reply reply + return handle + end + + def change_rss(m, params) + handle = params[:handle].downcase + feed = @feeds.fetch(handle, nil) + unless feed + m.reply "No such feed with handle #{handle}" + return + end + case params[:what].intern + when :handle + new = params[:new].downcase + if @feeds.key?(new) and @feeds[new] + m.reply "There already is a feed with handle #{new}" + return + else + feed.mutex.synchronize do + @feeds[new] = feed + @feeds.delete(handle) + feed.handle = new + end + handle = new + end + when :url + new = params[:new] + feed.mutex.synchronize do + feed.url = new + end + when :format, :type + new = params[:new] + new = nil if new == 'default' + feed.mutex.synchronize do + feed.type = new + end + when :refresh + new = params[:new].to_i + new = nil if new == 0 + feed.mutex.synchronize do + feed.refresh_rate = new + end + else + m.reply "Don't know how to change #{params[:what]} for feeds" + return + end + m.reply "Feed changed:" + list_rss(m, {:handle => handle}) + end + + def del_rss(m, params, pass=false) + feed = unwatch_rss(m, params, true) + return unless feed + if feed.watched? + m.reply "someone else is watching #{feed.handle}, I won't remove it from my list" + return + end + @feeds.delete(feed.handle.downcase) + m.okay unless pass + return + end + + def replace_rss(m, params) + handle = params[:handle] + if @feeds.key?(handle.downcase) + del_rss(m, {:handle => handle}, true) + end + if @feeds.key?(handle.downcase) + m.reply "can't replace #{feed.handle}" + else + add_rss(m, params, true) + end + end + + def forcereplace_rss(m, params) + add_rss(m, params, true) + end + + def watch_rss(m, params) + handle = params[:handle] + chan = params[:chan] || m.replyto + url = params[:url] + type = params[:type] + if url + add_rss(m, params) + end + feed = @feeds.fetch(handle.downcase, nil) + if feed + if feed.add_watch(chan) + watchRss(feed, m) + m.okay + else + m.reply "Already watching #{feed.handle} in #{chan}" + end + else + m.reply "Couldn't watch feed #{handle} (no such feed found)" + end + end + + def unwatch_rss(m, params, pass=false) + handle = params[:handle].downcase + chan = params[:chan] || m.replyto + unless @feeds.has_key?(handle) + m.reply("dunno that feed") + return + end + feed = @feeds[handle] + if feed.rm_watch(chan) + m.reply "#{chan} has been removed from the watchlist for #{feed.handle}" + else + m.reply("#{chan} wasn't watching #{feed.handle}") unless pass + end + if !feed.watched? + stop_watch(handle) + end + return feed + end + + def rewatch_rss(m=nil, params=nil) + if params and handle = params[:handle] + feed = @feeds.fetch(handle.downcase, nil) + if feed + @bot.timer.reschedule(@watch[feed.handle], 0) + m.okay if m + else + m.reply _("no such feed %{handle}") % { :handle => handle } if m + end + else + stop_watches + + # Read watches from list. + watchlist.each{ |handle, feed| + watchRss(feed, m) + } + m.okay if m + end + end + + private + def watchRss(feed, m=nil) + if @watch.has_key?(feed.handle) + report_problem("watcher thread for #{feed.handle} is already running", nil, m) + return + end + status = Hash.new + status[:failures] = 0 + tmout = 0 + if feed.last_fetched + tmout = feed.last_fetched + calculate_timeout(feed) - Time.now + tmout = 0 if tmout < 0 + end + debug "scheduling a watcher for #{feed} in #{tmout} seconds" + @watch[feed.handle] = @bot.timer.add(tmout) { + debug "watcher for #{feed} wakes up" + failures = status[:failures] + begin + debug "fetching #{feed}" + first_run = !feed.last_fetched + oldxml = feed.xml ? feed.xml.dup : nil + unless fetchRss(feed) + failures += 1 + else + if first_run + debug "first run for #{feed}, getting items" + parseRss(feed) + elsif oldxml and oldxml == feed.xml + debug "xml for #{feed} didn't change" + failures -= 1 if failures > 0 + else + if not feed.items + debug "no previous items in feed #{feed}" + parseRss(feed) + failures -= 1 if failures > 0 + else + # This one is used for debugging + otxt = [] + + # These are used for checking new items vs old ones + uid_opts = { :show_updated => @bot.config['rss.show_updated'] } + oids = Set.new feed.items.map { |item| + uid = RSS.item_uid_for_bot(item, uid_opts) + otxt << item.to_s + debug [uid, item].inspect + debug [uid, otxt.last].inspect + uid + } + + unless parseRss(feed) + debug "no items in feed #{feed}" + failures += 1 + else + debug "Checking if new items are available for #{feed}" + failures -= 1 if failures > 0 + # debug "Old:" + # debug oldxml + # debug "New:" + # debug feed.xml + + dispItems = feed.items.reject { |item| + uid = RSS.item_uid_for_bot(item, uid_opts) + txt = item.to_s + if oids.include?(uid) + debug "rejecting old #{uid} #{item.inspect}" + debug [uid, txt].inspect + true + else + debug "accepting new #{uid} #{item.inspect}" + debug [uid, txt].inspect + warning "same text! #{txt}" if otxt.include?(txt) + false + end + } + + if dispItems.length > 0 + debug "Found #{dispItems.length} new items in #{feed}" + # When displaying watched feeds, publish them from older to newer + dispItems.reverse.each { |item| + printFormattedRss(feed, item) + } + else + debug "No new items found in #{feed}" + end + end + end + end + end + rescue Exception => e + error "Error watching #{feed}: #{e.inspect}" + debug e.backtrace.join("\n") + failures += 1 + end + + status[:failures] = failures + + seconds = calculate_timeout(feed, failures) + debug "watcher for #{feed} going to sleep #{seconds} seconds.." + begin + @bot.timer.reschedule(@watch[feed.handle], seconds) + rescue + warning "watcher for #{feed} failed to reschedule: #{$!.inspect}" + end + } + debug "watcher for #{feed} added" + end + + def calculate_timeout(feed, failures = 0) + seconds = @bot.config['rss.thread_sleep'] + feed.mutex.synchronize do + seconds = feed.refresh_rate if feed.refresh_rate + end + seconds *= failures + 1 + seconds += seconds * (rand(100)-50)/100 + return seconds + end + + def select_nonempty(*ar) + debug ar + ret = ar.map { |i| (i && i.empty?) ? nil : i }.compact.first + (ret && ret.empty?) ? nil : ret + end + + def printFormattedRss(feed, item, opts=nil) + debug item + places = feed.watchers + handle = "::#{feed.handle}:: " + date = String.new + if opts + places = opts[:places] if opts.key?(:places) + handle = opts[:handle].to_s if opts.key?(:handle) + if opts.key?(:date) && opts[:date] + if item.respond_to?(:updated) + if item.updated.content.class <= Time + date = item.updated.content.strftime("%Y/%m/%d %H:%M") + else + date = item.updated.content.to_s + end + elsif item.respond_to?(:source) and item.source.respond_to?(:updated) + if item.source.updated.content.class <= Time + date = item.source.updated.content.strftime("%Y/%m/%d %H:%M") + else + date = item.source.updated.content.to_s + end + elsif item.respond_to?(:pubDate) + if item.pubDate.class <= Time + date = item.pubDate.strftime("%Y/%m/%d %H:%M") + else + date = item.pubDate.to_s + end + elsif item.respond_to?(:date) + if item.date.class <= Time + date = item.date.strftime("%Y/%m/%d %H:%M") + else + date = item.date.to_s + end + else + date = "(no date)" + end + date += " :: " + end + end + + tit_opt = {} + # Twitters don't need a cap on the title length since they have a hard + # limit to 160 characters, and most of them are under 140 characters + tit_opt[:limit] = @bot.config['rss.head_max'] unless feed.type == 'twitter' + + if item.title + base_title = item.title.to_s.dup + # git changesets are SHA1 hashes (40 hex digits), way too long, get rid of them, as they are + # visible in the URL anyway + # TODO make this optional? + base_title.sub!(/^Changeset \[([\da-f]{40})\]:/) { |c| "(git commit)"} if feed.type == 'trac' + title = "#{Bold}#{base_title.ircify_html(tit_opt)}#{Bold}" + end + + desc_opt = {} + desc_opt[:limit] = @bot.config['rss.text_max'] + desc_opt[:a_href] = :link_out if @bot.config['rss.show_links'] + + # We prefer content_encoded here as it tends to provide more html formatting + # for use with ircify_html. + if item.respond_to?(:content_encoded) && item.content_encoded + desc = item.content_encoded.ircify_html(desc_opt) + elsif item.respond_to?(:description) && item.description + desc = item.description.ircify_html(desc_opt) + else + if item.content.type == "html" + desc = item.content.content.ircify_html(desc_opt) + else + desc = item.content.content + if desc.size > desc_opt[:limit] + desc = desc.slice(0, desc_opt[:limit]) + "#{Reverse}...#{Reverse}" + end + end + end + + link = item.link.href rescue item.link.chomp rescue nil + + category = select_nonempty((item.category.content rescue nil), (item.dc_subject rescue nil)) + author = select_nonempty((item.author.name.content rescue nil), (item.dc_creator rescue nil), (item.author rescue nil)) + + line1 = nil + line2 = nil + + at = ((item.title && item.link) ? ' @ ' : '') + + case feed.type + when 'blog' + author += " " if author + abt = category ? "about #{category} " : "" + line1 = "#{handle}#{date}#{author}blogged #{abt}at #{link}" + line2 = "#{handle}#{title} - #{desc}" + when 'photoblog' + author += " " if author + abt = category ? "under #{category} " : "" + line1 = "#{handle}#{date}#{author}added an image #{abt}at #{link}" + line2 = "#{handle}#{title} - #{desc}" + when 'news' + line1 = "#{handle}#{date}#{title} @ #{link}" + line2 = line2 = "#{handle}#{date}#{desc}" + when 'git' + author += " " if author + line1 = "#{handle}#{date}#{author}commited #{title} @ #{link}" + when 'forum' + line1 = "#{handle}#{date}#{title}#{at}#{link}" + when 'wiki' + line1 = "#{handle}#{date}#{title}#{at}#{link} has been edited by #{author}. #{desc}" + when 'gmane' + line1 = "#{handle}#{date}Message #{title} sent by #{author}. #{desc}" + when 'trac' + line1 = "#{handle}#{date}#{title} @ #{link}" + unless item.title =~ /^(?:Changeset \[(?:[\da-f]+)\]|\(git commit\))/ + line2 = "#{handle}#{date}#{desc}" + end + when '/.' + dept = "(from the #{item.slash_department} dept) " rescue nil + sec = " in section #{item.slash_section}" rescue nil + + line1 = "#{handle}#{date}#{dept}#{title}#{at}#{link} (posted by #{author}#{sec})" + else + line1 = "#{handle}#{date}#{title}#{at}#{link}" + line1 << " (by #{author})" if author + end + places.each { |loc| + @bot.say loc, line1, :overlong => :truncate + next unless line2 + @bot.say loc, line2, :overlong => :truncate + } + end + + def fetchRss(feed, m=nil, cache=true) + feed.last_fetched = Time.now + begin + # Use 60 sec timeout, cause the default is too low + xml = @bot.httputil.get(feed.url, + :read_timeout => 60, + :open_timeout => 60, + :cache => cache) + rescue URI::InvalidURIError, URI::BadURIError => e + report_problem("invalid rss feed #{feed.url}", e, m) + return nil + rescue => e + report_problem("error getting #{feed.url}", e, m) + return nil + end + debug "fetched #{feed}" + unless xml + report_problem("reading feed #{feed} failed", nil, m) + return nil + end + # Ok, 0.9 feeds are not supported, maybe because + # Netscape happily removed the DTD. So what we do is just to + # reassign the 0.9 RDFs to 1.0, and hope it goes right. + xml.gsub!("xmlns=\"http://my.netscape.com/rdf/simple/0.9/\"", + "xmlns=\"http://purl.org/rss/1.0/\"") + feed.mutex.synchronize do + feed.xml = xml + end + return true + end + + def parseRss(feed, m=nil) + return nil unless feed.xml + feed.mutex.synchronize do + xml = feed.xml + begin + ## do validate parse + rss = RSS::Parser.parse(xml) + debug "parsed and validated #{feed}" + rescue RSS::InvalidRSSError + ## do non validate parse for invalid RSS 1.0 + begin + rss = RSS::Parser.parse(xml, false) + debug "parsed but not validated #{feed}" + rescue RSS::Error => e + report_problem("parsing rss stream failed, whoops =(", e, m) + return nil + end + rescue RSS::Error => e + report_problem("parsing rss stream failed, oioi", e, m) + return nil + rescue => e + report_problem("processing error occured, sorry =(", e, m) + return nil + end + items = [] + if rss.nil? + report_problem("#{feed} does not include RSS 1.0 or 0.9x/2.0", nil, m) + else + begin + rss.output_encoding = 'UTF-8' + rescue RSS::UnknownConvertMethod => e + report_problem("bah! something went wrong =(", e, m) + return nil + end + if rss.respond_to? :channel + rss.channel.title ||= "Unknown" + title = rss.channel.title + else + title = rss.title.content + end + rss.items.each do |item| + item.title ||= "Unknown" + items << item + end + end + + if items.empty? + report_problem("no items found in the feed, maybe try weed?", e, m) + return nil + end + feed.title = title + feed.items = items + return true + end + end +end + +plugin = RSSFeedsPlugin.new + +plugin.default_auth( 'edit', false ) +plugin.default_auth( 'edit:add', true) + +plugin.map 'rss show :handle :limit', + :action => 'show_rss', + :requirements => {:limit => /^\d+(?:\.\.\d+)?$/}, + :defaults => {:limit => 5} +plugin.map 'rss list :handle', + :action => 'list_rss', + :defaults => {:handle => nil} +plugin.map 'rss watched :handle [in :chan]', + :action => 'watched_rss', + :defaults => {:handle => nil} +plugin.map 'rss who watches :handle', + :action => 'who_watches', + :defaults => {:handle => nil} +plugin.map 'rss add :handle :url :type', + :action => 'add_rss', + :auth_path => 'edit', + :defaults => {:type => nil} +plugin.map 'rss change :what of :handle to :new', + :action => 'change_rss', + :auth_path => 'edit', + :requirements => { :what => /handle|url|format|type|refresh/ } +plugin.map 'rss change :what for :handle to :new', + :action => 'change_rss', + :auth_path => 'edit', + :requirements => { :what => /handle|url|format|type|refesh/ } +plugin.map 'rss del :handle', + :auth_path => 'edit:rm!', + :action => 'del_rss' +plugin.map 'rss delete :handle', + :auth_path => 'edit:rm!', + :action => 'del_rss' +plugin.map 'rss rm :handle', + :auth_path => 'edit:rm!', + :action => 'del_rss' +plugin.map 'rss replace :handle :url :type', + :auth_path => 'edit', + :action => 'replace_rss', + :defaults => {:type => nil} +plugin.map 'rss forcereplace :handle :url :type', + :auth_path => 'edit', + :action => 'forcereplace_rss', + :defaults => {:type => nil} +plugin.map 'rss watch :handle [in :chan]', + :action => 'watch_rss', + :defaults => {:url => nil, :type => nil} +plugin.map 'rss watch :handle :url :type [in :chan]', + :action => 'watch_rss', + :defaults => {:url => nil, :type => nil} +plugin.map 'rss unwatch :handle [in :chan]', + :action => 'unwatch_rss' +plugin.map 'rss rmwatch :handle [in :chan]', + :action => 'unwatch_rss' +plugin.map 'rss rewatch [:handle]', + :action => 'rewatch_rss' diff --git a/data/rbot/plugins/salut.rb b/data/rbot/plugins/salut.rb index abcb1744..569bade4 100644 --- a/data/rbot/plugins/salut.rb +++ b/data/rbot/plugins/salut.rb @@ -1,226 +1,226 @@ -#-- vim:sw=2:et
-#++
-#
-# :title: Salutations plugin for rbot
-#
-# Author:: Giuseppe "Oblomov" Bilotta <giuseppe.bilotta@gmail.com>
-# Copyright:: (C) 2006-2007 Giuseppe Bilotta
-# License:: GPL v2
-#
-# Salutations plugin: respond to salutations
-#
-# TODO:: allow online editing of salutations
-#
-# TODO:: *REMEMBER* to set @changed to true after edit or changes won't be saved
-
-class SalutPlugin < Plugin
- Config.register Config::BooleanValue.new('salut.all_languages',
- :default => true,
- :desc => "Check for a salutation in all languages and not just in the one defined by core.language",
- :on_change => Proc.new {|bot, v| bot.plugins['salut'].reload}
- )
- Config.register Config::BooleanValue.new('salut.address_only',
- :default => true,
- :desc => "When set to true, the bot will only reply to salutations directed at him",
- :on_change => Proc.new {|bot, v| bot.plugins['salut'].reload}
- )
-
-
- def initialize
- @salutations = Hash.new
- @match = Hash.new
- @match_langs = Array.new
- @main_lang_str = nil
- @main_lang = nil
- @all_langs = true
- @changed = false
- super
- reload
- end
-
- def set_language(what)
- reload
- end
-
- def create_match
- @match.clear
- ar_dest = Array.new
- ar_in = Array.new
- ar_out = Array.new
- ar_both = Array.new
- @salutations.each { |lang, hash|
- ar_dest.clear
- ar_in.clear
- ar_out.clear
- ar_both.clear
- hash.each { |situation, array|
- case situation.to_s
- when /^generic-dest$/
- ar_dest += array
- when /in$/
- ar_in += array
- when /out$/
- ar_out += array
- else
- ar_both += array
- end
- }
- @match[lang] = Hash.new
- @match[lang][:in] = Regexp.new("\\b(?:" + ar_in.uniq.map { |txt|
- Regexp.escape(txt)
- }.join('|') + ")\\b", Regexp::IGNORECASE) unless ar_in.empty?
- @match[lang][:out] = Regexp.new("\\b(?:" + ar_out.uniq.map { |txt|
- Regexp.escape(txt)
- }.join('|') + ")\\b", Regexp::IGNORECASE) unless ar_out.empty?
- @match[lang][:both] = Regexp.new("\\b(?:" + ar_both.uniq.map { |txt|
- Regexp.escape(txt)
- }.join('|') + ")\\b", Regexp::IGNORECASE) unless ar_both.empty?
- @match[lang][:dest] = Regexp.new("\\b(?:" + ar_dest.uniq.map { |txt|
- Regexp.escape(txt)
- }.join('|') + ")\\b", Regexp::IGNORECASE) unless ar_dest.empty?
- }
- @punct = /\s*[.,:!;?]?\s*/ # Punctuation
-
- # Languages to match for, in order
- @match_langs.clear
- @match_langs << @main_lang if @match.key?(@main_lang)
- @match_langs << :english if @match.key?(:english)
- @match.each_key { |key|
- @match_langs << key
- }
- @match_langs.uniq!
- end
-
- def unreplied(m)
- return if @match.empty?
- return unless m.kind_of?(PrivMessage)
- return if m.address? and m.plugin == 'config'
- to_me = m.address? || m.message =~ /#{Regexp.escape(@bot.nick)}/i
- if @bot.config['salut.address_only']
- return unless to_me
- end
- salut = nil
- @match_langs.each { |lang|
- [:both, :in, :out].each { |k|
- next unless @match[lang][k]
- if m.message =~ @match[lang][k]
- salut = [@match[lang][k], lang, k]
- break
- end
- }
- break if salut
- }
- return unless salut
- # If the bot wasn't addressed, we continue only if the match was exact
- # (apart from space and punctuation) or if @match[:dest] matches too
- return unless to_me or m.message =~ /^#{@punct}#{salut.first}#{@punct}$/ or m.message =~ @match[salut[1]][:dest]
- h = Time.new.hour
- case h
- when 4...12
- salut_reply(m, salut, :morning)
- when 12...18
- salut_reply(m, salut, :afternoon)
- else
- salut_reply(m, salut, :evening)
- end
- end
-
- def salut_reply(m, salut, time)
- lang = salut[1]
- k = salut[2]
- debug "Replying to #{salut.first} (#{lang} #{k}) in the #{time}"
- # salut_ar = @salutations[@main_lang].update @salutations[:english].update @salutations[lang]
- salut_ar = @salutations[lang]
- case k
- when :both
- sfx = ""
- else
- sfx = "-#{k}"
- end
- debug "Building array ..."
- rep_ar = Array.new
- rep_ar += salut_ar.fetch("#{time}#{sfx}".to_sym, [])
- rep_ar += salut_ar.fetch("#{time}".to_sym, []) unless sfx.empty?
- rep_ar += salut_ar.fetch("generic#{sfx}".to_sym, [])
- rep_ar += salut_ar.fetch("generic".to_sym, []) unless sfx.empty?
- debug "Choosing reply in #{rep_ar.inspect} ..."
- if rep_ar.empty?
- if m.public? # and (m.address? or m =~ /#{Regexp.escape(@bot.nick)}/)
- choice = @bot.lang.get("hello_X") % m.sourcenick
- else
- choice = @bot.lang.get("hello") % m.sourcenick
- end
- else
- choice = rep_ar.pick_one
- if m.public? and (m.address? or m.message =~ /#{Regexp.escape(@bot.nick)}/)
- choice += "#{[',',''].pick_one} #{m.sourcenick}"
- choice += [" :)", " :D", "!", "", "", ""].pick_one
- end
- end
- debug "Replying #{choice}"
- m.plainreply choice
- end
-
- def reload
- save
- @main_lang_str = @bot.config['core.language']
- @main_lang = @main_lang_str.to_sym
- @all_langs = @bot.config['salut.all_languages']
- if @all_langs
- # Get all available languages
- langs = Dir.new("#{@bot.botclass}/salut").collect {|f|
- f =~ /salut-([^.]+)/ ? $1 : nil
- }.compact
- langs.each { |lang|
- @salutations[lang.to_sym] = load_lang(lang)
- }
- else
- @salutations.clear
- @salutations[@main_lang] = load_lang(@main_lang_str)
- end
- create_match
- @changed = false
- end
-
- def load_lang(lang)
- dir = "#{@bot.botclass}/salut"
- if not File.exist?(dir)
- Dir.mkdir(dir)
- end
- file = "#{@bot.botclass}/salut/salut-#{lang}"
- if File.exist?(file)
- begin
- salutations = Hash.new
- content = YAML::load_file(file)
- content.each { |key, val|
- salutations[key.to_sym] = val
- }
- return salutations
- rescue
- error "failed to read salutations in #{lang}: #{$!}"
- end
- end
- return nil
- end
-
- def save
- return if @salutations.empty?
- return unless @changed
- @salutations.each { |lang, val|
- l = lang.to_s
- save_lang(lang, val)
- }
- @changed = false
- end
-
- def save_lang(lang, val)
- fn = "#{@bot.botclass}/salut/salut-#{lang}"
- Utils.safe_save(fn) { |file|
- file.puts val.to_yaml
- }
- end
-
-end
-
-plugin = SalutPlugin.new
-
+#-- vim:sw=2:et +#++ +# +# :title: Salutations plugin for rbot +# +# Author:: Giuseppe "Oblomov" Bilotta <giuseppe.bilotta@gmail.com> +# Copyright:: (C) 2006-2007 Giuseppe Bilotta +# License:: GPL v2 +# +# Salutations plugin: respond to salutations +# +# TODO:: allow online editing of salutations +# +# TODO:: *REMEMBER* to set @changed to true after edit or changes won't be saved + +class SalutPlugin < Plugin + Config.register Config::BooleanValue.new('salut.all_languages', + :default => true, + :desc => "Check for a salutation in all languages and not just in the one defined by core.language", + :on_change => Proc.new {|bot, v| bot.plugins['salut'].reload} + ) + Config.register Config::BooleanValue.new('salut.address_only', + :default => true, + :desc => "When set to true, the bot will only reply to salutations directed at him", + :on_change => Proc.new {|bot, v| bot.plugins['salut'].reload} + ) + + + def initialize + @salutations = Hash.new + @match = Hash.new + @match_langs = Array.new + @main_lang_str = nil + @main_lang = nil + @all_langs = true + @changed = false + super + reload + end + + def set_language(what) + reload + end + + def create_match + @match.clear + ar_dest = Array.new + ar_in = Array.new + ar_out = Array.new + ar_both = Array.new + @salutations.each { |lang, hash| + ar_dest.clear + ar_in.clear + ar_out.clear + ar_both.clear + hash.each { |situation, array| + case situation.to_s + when /^generic-dest$/ + ar_dest += array + when /in$/ + ar_in += array + when /out$/ + ar_out += array + else + ar_both += array + end + } + @match[lang] = Hash.new + @match[lang][:in] = Regexp.new("\\b(?:" + ar_in.uniq.map { |txt| + Regexp.escape(txt) + }.join('|') + ")\\b", Regexp::IGNORECASE) unless ar_in.empty? + @match[lang][:out] = Regexp.new("\\b(?:" + ar_out.uniq.map { |txt| + Regexp.escape(txt) + }.join('|') + ")\\b", Regexp::IGNORECASE) unless ar_out.empty? + @match[lang][:both] = Regexp.new("\\b(?:" + ar_both.uniq.map { |txt| + Regexp.escape(txt) + }.join('|') + ")\\b", Regexp::IGNORECASE) unless ar_both.empty? + @match[lang][:dest] = Regexp.new("\\b(?:" + ar_dest.uniq.map { |txt| + Regexp.escape(txt) + }.join('|') + ")\\b", Regexp::IGNORECASE) unless ar_dest.empty? + } + @punct = /\s*[.,:!;?]?\s*/ # Punctuation + + # Languages to match for, in order + @match_langs.clear + @match_langs << @main_lang if @match.key?(@main_lang) + @match_langs << :english if @match.key?(:english) + @match.each_key { |key| + @match_langs << key + } + @match_langs.uniq! + end + + def unreplied(m) + return if @match.empty? + return unless m.kind_of?(PrivMessage) + return if m.address? and m.plugin == 'config' + to_me = m.address? || m.message =~ /#{Regexp.escape(@bot.nick)}/i + if @bot.config['salut.address_only'] + return unless to_me + end + salut = nil + @match_langs.each { |lang| + [:both, :in, :out].each { |k| + next unless @match[lang][k] + if m.message =~ @match[lang][k] + salut = [@match[lang][k], lang, k] + break + end + } + break if salut + } + return unless salut + # If the bot wasn't addressed, we continue only if the match was exact + # (apart from space and punctuation) or if @match[:dest] matches too + return unless to_me or m.message =~ /^#{@punct}#{salut.first}#{@punct}$/ or m.message =~ @match[salut[1]][:dest] + h = Time.new.hour + case h + when 4...12 + salut_reply(m, salut, :morning) + when 12...18 + salut_reply(m, salut, :afternoon) + else + salut_reply(m, salut, :evening) + end + end + + def salut_reply(m, salut, time) + lang = salut[1] + k = salut[2] + debug "Replying to #{salut.first} (#{lang} #{k}) in the #{time}" + # salut_ar = @salutations[@main_lang].update @salutations[:english].update @salutations[lang] + salut_ar = @salutations[lang] + case k + when :both + sfx = "" + else + sfx = "-#{k}" + end + debug "Building array ..." + rep_ar = Array.new + rep_ar += salut_ar.fetch("#{time}#{sfx}".to_sym, []) + rep_ar += salut_ar.fetch("#{time}".to_sym, []) unless sfx.empty? + rep_ar += salut_ar.fetch("generic#{sfx}".to_sym, []) + rep_ar += salut_ar.fetch("generic".to_sym, []) unless sfx.empty? + debug "Choosing reply in #{rep_ar.inspect} ..." + if rep_ar.empty? + if m.public? # and (m.address? or m =~ /#{Regexp.escape(@bot.nick)}/) + choice = @bot.lang.get("hello_X") % m.sourcenick + else + choice = @bot.lang.get("hello") % m.sourcenick + end + else + choice = rep_ar.pick_one + if m.public? and (m.address? or m.message =~ /#{Regexp.escape(@bot.nick)}/) + choice += "#{[',',''].pick_one} #{m.sourcenick}" + choice += [" :)", " :D", "!", "", "", ""].pick_one + end + end + debug "Replying #{choice}" + m.plainreply choice + end + + def reload + save + @main_lang_str = @bot.config['core.language'] + @main_lang = @main_lang_str.to_sym + @all_langs = @bot.config['salut.all_languages'] + if @all_langs + # Get all available languages + langs = Dir.new("#{@bot.botclass}/salut").collect {|f| + f =~ /salut-([^.]+)/ ? $1 : nil + }.compact + langs.each { |lang| + @salutations[lang.to_sym] = load_lang(lang) + } + else + @salutations.clear + @salutations[@main_lang] = load_lang(@main_lang_str) + end + create_match + @changed = false + end + + def load_lang(lang) + dir = "#{@bot.botclass}/salut" + if not File.exist?(dir) + Dir.mkdir(dir) + end + file = "#{@bot.botclass}/salut/salut-#{lang}" + if File.exist?(file) + begin + salutations = Hash.new + content = YAML::load_file(file) + content.each { |key, val| + salutations[key.to_sym] = val + } + return salutations + rescue + error "failed to read salutations in #{lang}: #{$!}" + end + end + return nil + end + + def save + return if @salutations.empty? + return unless @changed + @salutations.each { |lang, val| + l = lang.to_s + save_lang(lang, val) + } + @changed = false + end + + def save_lang(lang, val) + fn = "#{@bot.botclass}/salut/salut-#{lang}" + Utils.safe_save(fn) { |file| + file.puts val.to_yaml + } + end + +end + +plugin = SalutPlugin.new + diff --git a/lib/rbot/botuser.rb b/lib/rbot/botuser.rb index b388b7f4..c77db4a5 100644 --- a/lib/rbot/botuser.rb +++ b/lib/rbot/botuser.rb @@ -1,924 +1,924 @@ -#-- vim:sw=2:et
-#++
-# :title: User management
-#
-# rbot user management
-# Author:: Giuseppe Bilotta (giuseppe.bilotta@gmail.com)
-# Copyright:: Copyright (c) 2006 Giuseppe Bilotta
-# License:: GPLv2
-
-require 'singleton'
-require 'set'
-require 'rbot/maskdb'
-
-# This would be a good idea if it was failproof, but the truth
-# is that other methods can indirectly modify the hash. *sigh*
-#
-# class AuthNotifyingHash < Hash
-# %w(clear default= delete delete_if replace invert
-# merge! update rehash reject! replace shift []= store).each { |m|
-# class_eval {
-# define_method(m) { |*a|
-# r = super(*a)
-# Irc::Bot::Auth.manager.set_changed
-# r
-# }
-# }
-# }
-# end
-#
-
-module Irc
-class Bot
-
-
- # This module contains the actual Authentication stuff
- #
- module Auth
-
- Config.register Config::StringValue.new( 'auth.password',
- :default => 'rbotauth', :wizard => true,
- :on_change => Proc.new {|bot, v| bot.auth.botowner.password = v},
- :desc => _('Password for the bot owner'))
- Config.register Config::BooleanValue.new( 'auth.login_by_mask',
- :default => 'true',
- :desc => _('Set false to prevent new botusers from logging in without a password when the user netmask is known'))
- Config.register Config::BooleanValue.new( 'auth.autologin',
- :default => 'true',
- :desc => _('Set false to prevent new botusers from recognizing IRC users without a need to manually login'))
- Config.register Config::BooleanValue.new( 'auth.autouser',
- :default => 'false',
- :desc => _('Set true to allow new botusers to be created automatically'))
- # Config.register Config::IntegerValue.new( 'auth.default_level',
- # :default => 10, :wizard => true,
- # :desc => 'The default level for new/unknown users' )
-
- # Generate a random password of length _l_
- #
- def Auth.random_password(l=8)
- pwd = ""
- l.times do
- pwd << (rand(26) + (rand(2) == 0 ? 65 : 97) ).chr
- end
- return pwd
- end
-
-
- # An Irc::Bot::Auth::Command defines a command by its "path":
- #
- # base::command::subcommand::subsubcommand::subsubsubcommand
- #
- class Command
-
- attr_reader :command, :path
-
- # A method that checks if a given _cmd_ is in a form that can be
- # reduced into a canonical command path, and if so, returns it
- #
- def sanitize_command_path(cmd)
- pre = cmd.to_s.downcase.gsub(/^\*?(?:::)?/,"").gsub(/::$/,"")
- return pre if pre.empty?
- return pre if pre =~ /^\S+(::\S+)*$/
- raise TypeError, "#{cmd.inspect} is not a valid command"
- end
-
- # Creates a new Command from a given string; you can then access
- # the command as a symbol with the :command method and the whole
- # path as :path
- #
- # Command.new("core::auth::save").path => [:"*", :"core", :"core::auth", :"core::auth::save"]
- #
- # Command.new("core::auth::save").command => :"core::auth::save"
- #
- def initialize(cmd)
- cmdpath = sanitize_command_path(cmd).split('::')
- seq = cmdpath.inject(["*"]) { |list, cmd|
- list << (list.length > 1 ? list.last + "::" : "") + cmd
- }
- @path = seq.map { |k|
- k.to_sym
- }
- @command = path.last
- debug "Created command #{@command.inspect} with path #{@path.pretty_inspect}"
- end
-
- # Returs self
- def to_irc_auth_command
- self
- end
-
- end
-
- end
-
-end
-end
-
-
-class String
-
- # Returns an Irc::Bot::Auth::Comand from the receiver
- def to_irc_auth_command
- Irc::Bot::Auth::Command.new(self)
- end
-
-end
-
-
-class Symbol
-
- # Returns an Irc::Bot::Auth::Comand from the receiver
- def to_irc_auth_command
- Irc::Bot::Auth::Command.new(self)
- end
-
-end
-
-
-module Irc
-class Bot
-
-
- module Auth
-
-
- # This class describes a permission set
- class PermissionSet
-
- attr_reader :perm
- # Create a new (empty) PermissionSet
- #
- def initialize
- @perm = {}
- end
-
- # Inspection simply inspects the internal hash
- def inspect
- @perm.inspect
- end
-
- # Sets the permission for command _cmd_ to _val_,
- #
- def set_permission(str, val)
- cmd = str.to_irc_auth_command
- case val
- when true, false
- @perm[cmd.command] = val
- when nil
- @perm.delete(cmd.command)
- else
- raise TypeError, "#{val.inspect} must be true or false" unless [true,false].include?(val)
- end
- end
-
- # Resets the permission for command _cmd_
- #
- def reset_permission(cmd)
- set_permission(cmd, nil)
- end
-
- # Tells if command _cmd_ is permitted. We do this by returning
- # the value of the deepest Command#path that matches.
- #
- def permit?(str)
- cmd = str.to_irc_auth_command
- # TODO user-configurable list of always-allowed commands,
- # for admins that want to set permissions -* for everybody
- return true if cmd.command == :login
- allow = nil
- cmd.path.reverse.each { |k|
- if @perm.has_key?(k)
- allow = @perm[k]
- break
- end
- }
- return allow
- end
-
- end
-
-
- # This is the error that gets raised when an invalid password is met
- #
- class InvalidPassword < RuntimeError
- end
-
-
- # This is the basic class for bot users: they have a username, a
- # password, a list of netmasks to match against, and a list of
- # permissions. A BotUser can be marked as 'transient', usually meaning
- # it's not intended for permanent storage. Transient BotUsers have lower
- # priority than nontransient ones for autologin purposes.
- #
- # To initialize a BotUser, you pass a _username_ and an optional
- # hash of options. Currently, only two options are recognized:
- #
- # transient:: true or false, determines if the BotUser is transient or
- # permanent (default is false, permanent BotUser).
- #
- # Transient BotUsers are initialized by prepending an
- # asterisk (*) to the username, and appending a sanitized
- # version of the object_id. The username can be empty.
- # A random password is generated.
- #
- # Permanent Botusers need the username as is, and no
- # password is generated.
- #
- # masks:: an array of Netmasks to initialize the NetmaskList. This
- # list is used as-is for permanent BotUsers.
- #
- # Transient BotUsers will alter the list elements which are
- # Irc::User by globbing the nick and any initial nonletter
- # part of the ident.
- #
- # The masks option is optional for permanent BotUsers, but
- # obligatory (non-empty) for transients.
- #
- class BotUser
-
- attr_reader :username
- attr_reader :password
- attr_reader :netmasks
- attr_reader :perm
- attr_writer :login_by_mask
- attr_writer :transient
-
- def autologin=(vnew)
- vold = @autologin
- @autologin = vnew
- if vold && !vnew
- @netmasks.each { |n| Auth.manager.maskdb.remove(self, n) }
- elsif vnew && !vold
- @netmasks.each { |n| Auth.manager.maskdb.add(self, n) }
- end
- end
-
- # Checks if the BotUser is transient
- def transient?
- @transient
- end
-
- # Checks if the BotUser is permanent (not transient)
- def permanent?
- !@transient
- end
-
- # Sets if the BotUser is permanent or not
- def permanent=(bool)
- @transient=!bool
- end
-
- # Make the BotUser permanent
- def make_permanent(name)
- raise TypeError, "permanent already" if permanent?
- @username = BotUser.sanitize_username(name)
- @transient = false
- reset_autologin
- reset_password # or not?
- @netmasks.dup.each do |m|
- delete_netmask(m)
- add_netmask(m.generalize)
- end
- end
-
- # Create a new BotUser with given username
- def initialize(username, options={})
- opts = {:transient => false}.merge(options)
- @transient = opts[:transient]
-
- if @transient
- @username = "*"
- @username << BotUser.sanitize_username(username) if username and not username.to_s.empty?
- @username << BotUser.sanitize_username(object_id)
- reset_password
- @login_by_mask=true
- @autologin=true
- else
- @username = BotUser.sanitize_username(username)
- @password = nil
- reset_login_by_mask
- reset_autologin
- end
-
- @netmasks = NetmaskList.new
- if opts.key?(:masks) and opts[:masks]
- masks = opts[:masks]
- masks = [masks] unless masks.respond_to?(:each)
- masks.each { |m|
- mask = m.to_irc_netmask
- if @transient and User === m
- mask.nick = "*"
- mask.host = m.host.dup
- mask.user = "*" + m.user.sub(/^\w?[^\w]+/,'')
- end
- add_netmask(mask) unless mask.to_s == "*"
- }
- end
- raise "must provide a usable mask for transient BotUser #{@username}" if @transient and @netmasks.empty?
-
- @perm = {}
- end
-
- # Inspection
- def inspect
- str = self.__to_s__[0..-2]
- str << " (transient)" if @transient
- str << ":"
- str << " @username=#{@username.inspect}"
- str << " @netmasks=#{@netmasks.inspect}"
- str << " @perm=#{@perm.inspect}"
- str << " @login_by_mask=#{@login_by_mask}"
- str << " @autologin=#{@autologin}"
- str << ">"
- end
-
- # In strings
- def to_s
- @username
- end
-
- # Convert into a hash
- def to_hash
- {
- :username => @username,
- :password => @password,
- :netmasks => @netmasks,
- :perm => @perm,
- :login_by_mask => @login_by_mask,
- :autologin => @autologin,
- }
- end
-
- # Do we allow logging in without providing the password?
- #
- def login_by_mask?
- @login_by_mask
- end
-
- # Reset the login-by-mask option
- #
- def reset_login_by_mask
- @login_by_mask = Auth.manager.bot.config['auth.login_by_mask'] unless defined?(@login_by_mask)
- end
-
- # Reset the autologin option
- #
- def reset_autologin
- @autologin = Auth.manager.bot.config['auth.autologin'] unless defined?(@autologin)
- end
-
- # Do we allow automatic logging in?
- #
- def autologin?
- @autologin
- end
-
- # Restore from hash
- def from_hash(h)
- @username = h[:username] if h.has_key?(:username)
- @password = h[:password] if h.has_key?(:password)
- @login_by_mask = h[:login_by_mask] if h.has_key?(:login_by_mask)
- @autologin = h[:autologin] if h.has_key?(:autologin)
- if h.has_key?(:netmasks)
- @netmasks = h[:netmasks]
- debug @netmasks
- @netmasks.each { |n| Auth.manager.maskdb.add(self, n) } if @autologin
- debug @netmasks
- end
- @perm = h[:perm] if h.has_key?(:perm)
- end
-
- # This method sets the password if the proposed new password
- # is valid
- def password=(pwd=nil)
- pass = pwd.to_s
- if pass.empty?
- reset_password
- else
- begin
- raise InvalidPassword, "#{pass} contains invalid characters" if pass !~ /^[\x21-\x7e]+$/
- raise InvalidPassword, "#{pass} too short" if pass.length < 4
- @password = pass
- rescue InvalidPassword => e
- raise e
- rescue => e
- raise InvalidPassword, "Exception #{e.inspect} while checking #{pass.inspect} (#{pwd.inspect})"
- end
- end
- end
-
- # Resets the password by creating a new onw
- def reset_password
- @password = Auth.random_password
- end
-
- # Sets the permission for command _cmd_ to _val_ on channel _chan_
- #
- def set_permission(cmd, val, chan="*")
- k = chan.to_s.to_sym
- @perm[k] = PermissionSet.new unless @perm.has_key?(k)
- @perm[k].set_permission(cmd, val)
- end
-
- # Resets the permission for command _cmd_ on channel _chan_
- #
- def reset_permission(cmd, chan ="*")
- set_permission(cmd, nil, chan)
- end
-
- # Checks if BotUser is allowed to do something on channel _chan_,
- # or on all channels if _chan_ is nil
- #
- def permit?(cmd, chan=nil)
- if chan
- k = chan.to_s.to_sym
- else
- k = :*
- end
- allow = nil
- if @perm.has_key?(k)
- allow = @perm[k].permit?(cmd)
- end
- return allow
- end
-
- # Adds a Netmask
- #
- def add_netmask(mask)
- m = mask.to_irc_netmask
- @netmasks << m
- if self.autologin?
- Auth.manager.maskdb.add(self, m)
- Auth.manager.logout_transients(m) if self.permanent?
- end
- end
-
- # Removes a Netmask
- #
- def delete_netmask(mask)
- m = mask.to_irc_netmask
- @netmasks.delete(m)
- Auth.manager.maskdb.remove(self, m) if self.autologin?
- end
-
- # Reset Netmasks, clearing @netmasks
- #
- def reset_netmasks
- @netmasks.each { |m|
- Auth.manager.maskdb.remove(self, m) if self.autologin?
- }
- @netmasks.clear
- end
-
- # This method checks if BotUser has a Netmask that matches _user_
- #
- def knows?(usr)
- user = usr.to_irc_user
- !!@netmasks.find { |n| user.matches? n }
- end
-
- # This method gets called when User _user_ wants to log in.
- # It returns true or false depending on whether the password
- # is right. If it is, the Netmask of the user is added to the
- # list of acceptable Netmask unless it's already matched.
- def login(user, password=nil)
- if password == @password or (password.nil? and (@login_by_mask || @autologin) and knows?(user))
- add_netmask(user) unless knows?(user)
- debug "#{user} logged in as #{self.inspect}"
- return true
- else
- return false
- end
- end
-
- # # This method gets called when User _user_ has logged out as this BotUser
- # def logout(user)
- # delete_netmask(user) if knows?(user)
- # end
-
- # This method sanitizes a username by chomping, downcasing
- # and replacing any nonalphanumeric character with _
- #
- def BotUser.sanitize_username(name)
- candidate = name.to_s.chomp.downcase.gsub(/[^a-z0-9]/,"_")
- raise "sanitized botusername #{candidate} too short" if candidate.length < 3
- return candidate
- end
-
- end
-
- # This is the default BotUser: it's used for all users which haven't
- # identified with the bot
- #
- class DefaultBotUserClass < BotUser
-
- private :add_netmask, :delete_netmask
-
- include Singleton
-
- # The default BotUser is named 'everyone'
- #
- def initialize
- reset_login_by_mask
- reset_autologin
- super("everyone")
- @default_perm = PermissionSet.new
- end
-
- # This method returns without changing anything
- #
- def login_by_mask=(val)
- debug "Tried to change the login-by-mask for default bot user, ignoring"
- return @login_by_mask
- end
-
- # The default botuser allows logins by mask
- #
- def reset_login_by_mask
- @login_by_mask = true
- end
-
- # This method returns without changing anything
- #
- def autologin=(val)
- debug "Tried to change the autologin for default bot user, ignoring"
- return
- end
-
- # The default botuser doesn't allow autologin (meaningless)
- #
- def reset_autologin
- @autologin = false
- end
-
- # Sets the default permission for the default user (i.e. the ones
- # set by the BotModule writers) on all channels
- #
- def set_default_permission(cmd, val)
- @default_perm.set_permission(Command.new(cmd), val)
- debug "Default permissions now: #{@default_perm.pretty_inspect}"
- end
-
- # default knows everybody
- #
- def knows?(user)
- return true if user.to_irc_user
- end
-
- # We always allow logging in as the default user
- def login(user, password)
- return true
- end
-
- # DefaultBotUser will check the default_perm after checking
- # the global ones
- # or on all channels if _chan_ is nil
- #
- def permit?(cmd, chan=nil)
- allow = super(cmd, chan)
- if allow.nil? && chan.nil?
- allow = @default_perm.permit?(cmd)
- end
- return allow
- end
-
- end
-
- # Returns the only instance of DefaultBotUserClass
- #
- def Auth.defaultbotuser
- return DefaultBotUserClass.instance
- end
-
- # This is the BotOwner: he can do everything
- #
- class BotOwnerClass < BotUser
-
- include Singleton
-
- def initialize
- @login_by_mask = false
- @autologin = true
- super("owner")
- end
-
- def permit?(cmd, chan=nil)
- return true
- end
-
- end
-
- # Returns the only instance of BotOwnerClass
- #
- def Auth.botowner
- return BotOwnerClass.instance
- end
-
-
- class BotUser
- # Check if the current BotUser is the default one
- def default?
- return DefaultBotUserClass === self
- end
-
- # Check if the current BotUser is the owner
- def owner?
- return BotOwnerClass === self
- end
- end
-
-
- # This is the ManagerClass singleton, used to manage
- # Irc::User/Irc::Bot::Auth::BotUser connections and everything
- #
- class ManagerClass
-
- include Singleton
-
- attr_reader :maskdb
- attr_reader :everyone
- attr_reader :botowner
- attr_reader :bot
-
- # The instance manages two <code>Hash</code>es: one that maps
- # <code>Irc::User</code>s onto <code>BotUser</code>s, and the other that maps
- # usernames onto <code>BotUser</code>
- def initialize
- @everyone = Auth::defaultbotuser
- @botowner = Auth::botowner
- bot_associate(nil)
- end
-
- def bot_associate(bot)
- raise "Cannot associate with a new bot! Save first" if defined?(@has_changes) && @has_changes
-
- reset_hashes
-
- # Associated bot
- @bot = bot
-
- # This variable is set to true when there have been changes
- # to the botusers list, so that we know when to save
- @has_changes = false
- end
-
- def set_changed
- @has_changes = true
- end
-
- def reset_changed
- @has_changes = false
- end
-
- def changed?
- @has_changes
- end
-
- # resets the hashes
- def reset_hashes
- @botusers = Hash.new
- @maskdb = NetmaskDb.new
- @allbotusers = Hash.new
- [everyone, botowner].each do |x|
- @allbotusers[x.username.to_sym] = x
- end
- end
-
- def load_array(ary, forced)
- unless ary
- warning "Tried to load an empty array"
- return
- end
- raise "Won't load with unsaved changes" if @has_changes and not forced
- reset_hashes
- ary.each { |x|
- raise TypeError, "#{x} should be a Hash" unless x.kind_of?(Hash)
- u = x[:username]
- unless include?(u)
- create_botuser(u)
- end
- get_botuser(u).from_hash(x)
- get_botuser(u).transient = false
- }
- @has_changes=false
- end
-
- def save_array
- @allbotusers.values.map { |x|
- x.transient? ? nil : x.to_hash
- }.compact
- end
-
- # checks if we know about a certain BotUser username
- def include?(botusername)
- @allbotusers.has_key?(botusername.to_sym)
- end
-
- # Maps <code>Irc::User</code> to BotUser
- def irc_to_botuser(ircuser)
- logged = @botusers[ircuser.to_irc_user]
- return logged if logged
- return autologin(ircuser)
- end
-
- # creates a new BotUser
- def create_botuser(name, password=nil)
- n = BotUser.sanitize_username(name)
- k = n.to_sym
- raise "botuser #{n} exists" if include?(k)
- bu = BotUser.new(n)
- bu.password = password
- @allbotusers[k] = bu
- return bu
- end
-
- # returns the botuser with name _name_
- def get_botuser(name)
- @allbotusers.fetch(BotUser.sanitize_username(name).to_sym)
- end
-
- # Logs Irc::User _user_ in to BotUser _botusername_ with password _pwd_
- #
- # raises an error if _botusername_ is not a known BotUser username
- #
- # It is possible to autologin by Netmask, on request
- #
- def login(user, botusername, pwd=nil)
- ircuser = user.to_irc_user
- n = BotUser.sanitize_username(botusername)
- k = n.to_sym
- raise "No such BotUser #{n}" unless include?(k)
- if @botusers.has_key?(ircuser)
- return true if @botusers[ircuser].username == n
- # TODO
- # @botusers[ircuser].logout(ircuser)
- end
- bu = @allbotusers[k]
- if bu.login(ircuser, pwd)
- @botusers[ircuser] = bu
- return true
- end
- return false
- end
-
- # Tries to auto-login Irc::User _user_ by looking at the known botusers that allow autologin
- # and trying to login without a password
- #
- def autologin(user)
- ircuser = user.to_irc_user
- debug "Trying to autologin #{ircuser}"
- return @botusers[ircuser] if @botusers.has_key?(ircuser)
- bu = maskdb.find(ircuser)
- if bu
- debug "trying #{bu}"
- bu.login(ircuser) or raise '...what?!'
- @botusers[ircuser] = bu
- return bu
- end
- # Finally, create a transient if we're set to allow it
- if @bot.config['auth.autouser']
- bu = create_transient_botuser(ircuser)
- @botusers[ircuser] = bu
- return bu
- end
- return everyone
- end
-
- # Creates a new transient BotUser associated with Irc::User _user_,
- # automatically logging him in. Note that transient botuser creation can
- # fail, typically if we don't have the complete user netmask (e.g. for
- # messages coming in from a linkbot)
- #
- def create_transient_botuser(user)
- ircuser = user.to_irc_user
- bu = everyone
- begin
- bu = BotUser.new(ircuser, :transient => true, :masks => ircuser)
- bu.login(ircuser)
- rescue
- warning "failed to create transient for #{user}"
- error $!
- end
- return bu
- end
-
- # Logs out any Irc::User matching Irc::Netmask _m_ and logged in
- # to a transient BotUser
- #
- def logout_transients(m)
- debug "to check: #{@botusers.keys.join ' '}"
- @botusers.keys.each do |iu|
- debug "checking #{iu.fullform} against #{m.fullform}"
- bu = @botusers[iu]
- bu.transient? or next
- iu.matches?(m) or next
- @botusers.delete(iu).autologin = false
- end
- end
-
- # Makes transient BotUser _user_ into a permanent BotUser
- # named _name_; if _user_ is an Irc::User, act on the transient
- # BotUser (if any) it's logged in as
- #
- def make_permanent(user, name)
- buname = BotUser.sanitize_username(name)
- # TODO merge BotUser instead?
- raise "there's already a BotUser called #{name}" if include?(buname)
-
- tuser = nil
- case user
- when String, Irc::User
- tuser = irc_to_botuser(user)
- when BotUser
- tuser = user
- else
- raise TypeError, "sorry, don't know how to make #{user.class} into a permanent BotUser"
- end
- return nil unless tuser
- raise TypeError, "#{tuser} is not transient" unless tuser.transient?
-
- tuser.make_permanent(buname)
- @allbotusers[tuser.username.to_sym] = tuser
-
- return tuser
- end
-
- # Checks if User _user_ can do _cmd_ on _chan_.
- #
- # Permission are checked in this order, until a true or false
- # is returned:
- # * associated BotUser on _chan_
- # * associated BotUser on all channels
- # * everyone on _chan_
- # * everyone on all channels
- #
- def permit?(user, cmdtxt, channel=nil)
- if user.class <= BotUser
- botuser = user
- else
- botuser = irc_to_botuser(user)
- end
- cmd = cmdtxt.to_irc_auth_command
-
- chan = channel
- case chan
- when User
- chan = "?"
- when Channel
- chan = chan.name
- end
-
- allow = nil
-
- allow = botuser.permit?(cmd, chan) if chan
- return allow unless allow.nil?
- allow = botuser.permit?(cmd)
- return allow unless allow.nil?
-
- unless botuser == everyone
- allow = everyone.permit?(cmd, chan) if chan
- return allow unless allow.nil?
- allow = everyone.permit?(cmd)
- return allow unless allow.nil?
- end
-
- raise "Could not check permission for user #{user.inspect} to run #{cmdtxt.inspect} on #{chan.inspect}"
- end
-
- # Checks if command _cmd_ is allowed to User _user_ on _chan_, optionally
- # telling if the user is authorized
- #
- def allow?(cmdtxt, user, chan=nil)
- if permit?(user, cmdtxt, chan)
- return true
- else
- # cmds = cmdtxt.split('::')
- # @bot.say chan, "you don't have #{cmds.last} (#{cmds.first}) permissions here" if chan
- @bot.say chan, _("%{user}, you don't have '%{command}' permissions here") %
- {:user=>user, :command=>cmdtxt} if chan
- return false
- end
- end
-
- end
-
- # Returns the only instance of ManagerClass
- #
- def Auth.manager
- return ManagerClass.instance
- end
-
- end
-end
-
- class User
-
- # A convenience method to automatically found the botuser
- # associated with the receiver
- #
- def botuser
- Irc::Bot::Auth.manager.irc_to_botuser(self)
- end
- end
-
-end
+#-- vim:sw=2:et +#++ +# :title: User management +# +# rbot user management +# Author:: Giuseppe Bilotta (giuseppe.bilotta@gmail.com) +# Copyright:: Copyright (c) 2006 Giuseppe Bilotta +# License:: GPLv2 + +require 'singleton' +require 'set' +require 'rbot/maskdb' + +# This would be a good idea if it was failproof, but the truth +# is that other methods can indirectly modify the hash. *sigh* +# +# class AuthNotifyingHash < Hash +# %w(clear default= delete delete_if replace invert +# merge! update rehash reject! replace shift []= store).each { |m| +# class_eval { +# define_method(m) { |*a| +# r = super(*a) +# Irc::Bot::Auth.manager.set_changed +# r +# } +# } +# } +# end +# + +module Irc +class Bot + + + # This module contains the actual Authentication stuff + # + module Auth + + Config.register Config::StringValue.new( 'auth.password', + :default => 'rbotauth', :wizard => true, + :on_change => Proc.new {|bot, v| bot.auth.botowner.password = v}, + :desc => _('Password for the bot owner')) + Config.register Config::BooleanValue.new( 'auth.login_by_mask', + :default => 'true', + :desc => _('Set false to prevent new botusers from logging in without a password when the user netmask is known')) + Config.register Config::BooleanValue.new( 'auth.autologin', + :default => 'true', + :desc => _('Set false to prevent new botusers from recognizing IRC users without a need to manually login')) + Config.register Config::BooleanValue.new( 'auth.autouser', + :default => 'false', + :desc => _('Set true to allow new botusers to be created automatically')) + # Config.register Config::IntegerValue.new( 'auth.default_level', + # :default => 10, :wizard => true, + # :desc => 'The default level for new/unknown users' ) + + # Generate a random password of length _l_ + # + def Auth.random_password(l=8) + pwd = "" + l.times do + pwd << (rand(26) + (rand(2) == 0 ? 65 : 97) ).chr + end + return pwd + end + + + # An Irc::Bot::Auth::Command defines a command by its "path": + # + # base::command::subcommand::subsubcommand::subsubsubcommand + # + class Command + + attr_reader :command, :path + + # A method that checks if a given _cmd_ is in a form that can be + # reduced into a canonical command path, and if so, returns it + # + def sanitize_command_path(cmd) + pre = cmd.to_s.downcase.gsub(/^\*?(?:::)?/,"").gsub(/::$/,"") + return pre if pre.empty? + return pre if pre =~ /^\S+(::\S+)*$/ + raise TypeError, "#{cmd.inspect} is not a valid command" + end + + # Creates a new Command from a given string; you can then access + # the command as a symbol with the :command method and the whole + # path as :path + # + # Command.new("core::auth::save").path => [:"*", :"core", :"core::auth", :"core::auth::save"] + # + # Command.new("core::auth::save").command => :"core::auth::save" + # + def initialize(cmd) + cmdpath = sanitize_command_path(cmd).split('::') + seq = cmdpath.inject(["*"]) { |list, cmd| + list << (list.length > 1 ? list.last + "::" : "") + cmd + } + @path = seq.map { |k| + k.to_sym + } + @command = path.last + debug "Created command #{@command.inspect} with path #{@path.pretty_inspect}" + end + + # Returs self + def to_irc_auth_command + self + end + + end + + end + +end +end + + +class String + + # Returns an Irc::Bot::Auth::Comand from the receiver + def to_irc_auth_command + Irc::Bot::Auth::Command.new(self) + end + +end + + +class Symbol + + # Returns an Irc::Bot::Auth::Comand from the receiver + def to_irc_auth_command + Irc::Bot::Auth::Command.new(self) + end + +end + + +module Irc +class Bot + + + module Auth + + + # This class describes a permission set + class PermissionSet + + attr_reader :perm + # Create a new (empty) PermissionSet + # + def initialize + @perm = {} + end + + # Inspection simply inspects the internal hash + def inspect + @perm.inspect + end + + # Sets the permission for command _cmd_ to _val_, + # + def set_permission(str, val) + cmd = str.to_irc_auth_command + case val + when true, false + @perm[cmd.command] = val + when nil + @perm.delete(cmd.command) + else + raise TypeError, "#{val.inspect} must be true or false" unless [true,false].include?(val) + end + end + + # Resets the permission for command _cmd_ + # + def reset_permission(cmd) + set_permission(cmd, nil) + end + + # Tells if command _cmd_ is permitted. We do this by returning + # the value of the deepest Command#path that matches. + # + def permit?(str) + cmd = str.to_irc_auth_command + # TODO user-configurable list of always-allowed commands, + # for admins that want to set permissions -* for everybody + return true if cmd.command == :login + allow = nil + cmd.path.reverse.each { |k| + if @perm.has_key?(k) + allow = @perm[k] + break + end + } + return allow + end + + end + + + # This is the error that gets raised when an invalid password is met + # + class InvalidPassword < RuntimeError + end + + + # This is the basic class for bot users: they have a username, a + # password, a list of netmasks to match against, and a list of + # permissions. A BotUser can be marked as 'transient', usually meaning + # it's not intended for permanent storage. Transient BotUsers have lower + # priority than nontransient ones for autologin purposes. + # + # To initialize a BotUser, you pass a _username_ and an optional + # hash of options. Currently, only two options are recognized: + # + # transient:: true or false, determines if the BotUser is transient or + # permanent (default is false, permanent BotUser). + # + # Transient BotUsers are initialized by prepending an + # asterisk (*) to the username, and appending a sanitized + # version of the object_id. The username can be empty. + # A random password is generated. + # + # Permanent Botusers need the username as is, and no + # password is generated. + # + # masks:: an array of Netmasks to initialize the NetmaskList. This + # list is used as-is for permanent BotUsers. + # + # Transient BotUsers will alter the list elements which are + # Irc::User by globbing the nick and any initial nonletter + # part of the ident. + # + # The masks option is optional for permanent BotUsers, but + # obligatory (non-empty) for transients. + # + class BotUser + + attr_reader :username + attr_reader :password + attr_reader :netmasks + attr_reader :perm + attr_writer :login_by_mask + attr_writer :transient + + def autologin=(vnew) + vold = @autologin + @autologin = vnew + if vold && !vnew + @netmasks.each { |n| Auth.manager.maskdb.remove(self, n) } + elsif vnew && !vold + @netmasks.each { |n| Auth.manager.maskdb.add(self, n) } + end + end + + # Checks if the BotUser is transient + def transient? + @transient + end + + # Checks if the BotUser is permanent (not transient) + def permanent? + !@transient + end + + # Sets if the BotUser is permanent or not + def permanent=(bool) + @transient=!bool + end + + # Make the BotUser permanent + def make_permanent(name) + raise TypeError, "permanent already" if permanent? + @username = BotUser.sanitize_username(name) + @transient = false + reset_autologin + reset_password # or not? + @netmasks.dup.each do |m| + delete_netmask(m) + add_netmask(m.generalize) + end + end + + # Create a new BotUser with given username + def initialize(username, options={}) + opts = {:transient => false}.merge(options) + @transient = opts[:transient] + + if @transient + @username = "*" + @username << BotUser.sanitize_username(username) if username and not username.to_s.empty? + @username << BotUser.sanitize_username(object_id) + reset_password + @login_by_mask=true + @autologin=true + else + @username = BotUser.sanitize_username(username) + @password = nil + reset_login_by_mask + reset_autologin + end + + @netmasks = NetmaskList.new + if opts.key?(:masks) and opts[:masks] + masks = opts[:masks] + masks = [masks] unless masks.respond_to?(:each) + masks.each { |m| + mask = m.to_irc_netmask + if @transient and User === m + mask.nick = "*" + mask.host = m.host.dup + mask.user = "*" + m.user.sub(/^\w?[^\w]+/,'') + end + add_netmask(mask) unless mask.to_s == "*" + } + end + raise "must provide a usable mask for transient BotUser #{@username}" if @transient and @netmasks.empty? + + @perm = {} + end + + # Inspection + def inspect + str = self.__to_s__[0..-2] + str << " (transient)" if @transient + str << ":" + str << " @username=#{@username.inspect}" + str << " @netmasks=#{@netmasks.inspect}" + str << " @perm=#{@perm.inspect}" + str << " @login_by_mask=#{@login_by_mask}" + str << " @autologin=#{@autologin}" + str << ">" + end + + # In strings + def to_s + @username + end + + # Convert into a hash + def to_hash + { + :username => @username, + :password => @password, + :netmasks => @netmasks, + :perm => @perm, + :login_by_mask => @login_by_mask, + :autologin => @autologin, + } + end + + # Do we allow logging in without providing the password? + # + def login_by_mask? + @login_by_mask + end + + # Reset the login-by-mask option + # + def reset_login_by_mask + @login_by_mask = Auth.manager.bot.config['auth.login_by_mask'] unless defined?(@login_by_mask) + end + + # Reset the autologin option + # + def reset_autologin + @autologin = Auth.manager.bot.config['auth.autologin'] unless defined?(@autologin) + end + + # Do we allow automatic logging in? + # + def autologin? + @autologin + end + + # Restore from hash + def from_hash(h) + @username = h[:username] if h.has_key?(:username) + @password = h[:password] if h.has_key?(:password) + @login_by_mask = h[:login_by_mask] if h.has_key?(:login_by_mask) + @autologin = h[:autologin] if h.has_key?(:autologin) + if h.has_key?(:netmasks) + @netmasks = h[:netmasks] + debug @netmasks + @netmasks.each { |n| Auth.manager.maskdb.add(self, n) } if @autologin + debug @netmasks + end + @perm = h[:perm] if h.has_key?(:perm) + end + + # This method sets the password if the proposed new password + # is valid + def password=(pwd=nil) + pass = pwd.to_s + if pass.empty? + reset_password + else + begin + raise InvalidPassword, "#{pass} contains invalid characters" if pass !~ /^[\x21-\x7e]+$/ + raise InvalidPassword, "#{pass} too short" if pass.length < 4 + @password = pass + rescue InvalidPassword => e + raise e + rescue => e + raise InvalidPassword, "Exception #{e.inspect} while checking #{pass.inspect} (#{pwd.inspect})" + end + end + end + + # Resets the password by creating a new onw + def reset_password + @password = Auth.random_password + end + + # Sets the permission for command _cmd_ to _val_ on channel _chan_ + # + def set_permission(cmd, val, chan="*") + k = chan.to_s.to_sym + @perm[k] = PermissionSet.new unless @perm.has_key?(k) + @perm[k].set_permission(cmd, val) + end + + # Resets the permission for command _cmd_ on channel _chan_ + # + def reset_permission(cmd, chan ="*") + set_permission(cmd, nil, chan) + end + + # Checks if BotUser is allowed to do something on channel _chan_, + # or on all channels if _chan_ is nil + # + def permit?(cmd, chan=nil) + if chan + k = chan.to_s.to_sym + else + k = :* + end + allow = nil + if @perm.has_key?(k) + allow = @perm[k].permit?(cmd) + end + return allow + end + + # Adds a Netmask + # + def add_netmask(mask) + m = mask.to_irc_netmask + @netmasks << m + if self.autologin? + Auth.manager.maskdb.add(self, m) + Auth.manager.logout_transients(m) if self.permanent? + end + end + + # Removes a Netmask + # + def delete_netmask(mask) + m = mask.to_irc_netmask + @netmasks.delete(m) + Auth.manager.maskdb.remove(self, m) if self.autologin? + end + + # Reset Netmasks, clearing @netmasks + # + def reset_netmasks + @netmasks.each { |m| + Auth.manager.maskdb.remove(self, m) if self.autologin? + } + @netmasks.clear + end + + # This method checks if BotUser has a Netmask that matches _user_ + # + def knows?(usr) + user = usr.to_irc_user + !!@netmasks.find { |n| user.matches? n } + end + + # This method gets called when User _user_ wants to log in. + # It returns true or false depending on whether the password + # is right. If it is, the Netmask of the user is added to the + # list of acceptable Netmask unless it's already matched. + def login(user, password=nil) + if password == @password or (password.nil? and (@login_by_mask || @autologin) and knows?(user)) + add_netmask(user) unless knows?(user) + debug "#{user} logged in as #{self.inspect}" + return true + else + return false + end + end + + # # This method gets called when User _user_ has logged out as this BotUser + # def logout(user) + # delete_netmask(user) if knows?(user) + # end + + # This method sanitizes a username by chomping, downcasing + # and replacing any nonalphanumeric character with _ + # + def BotUser.sanitize_username(name) + candidate = name.to_s.chomp.downcase.gsub(/[^a-z0-9]/,"_") + raise "sanitized botusername #{candidate} too short" if candidate.length < 3 + return candidate + end + + end + + # This is the default BotUser: it's used for all users which haven't + # identified with the bot + # + class DefaultBotUserClass < BotUser + + private :add_netmask, :delete_netmask + + include Singleton + + # The default BotUser is named 'everyone' + # + def initialize + reset_login_by_mask + reset_autologin + super("everyone") + @default_perm = PermissionSet.new + end + + # This method returns without changing anything + # + def login_by_mask=(val) + debug "Tried to change the login-by-mask for default bot user, ignoring" + return @login_by_mask + end + + # The default botuser allows logins by mask + # + def reset_login_by_mask + @login_by_mask = true + end + + # This method returns without changing anything + # + def autologin=(val) + debug "Tried to change the autologin for default bot user, ignoring" + return + end + + # The default botuser doesn't allow autologin (meaningless) + # + def reset_autologin + @autologin = false + end + + # Sets the default permission for the default user (i.e. the ones + # set by the BotModule writers) on all channels + # + def set_default_permission(cmd, val) + @default_perm.set_permission(Command.new(cmd), val) + debug "Default permissions now: #{@default_perm.pretty_inspect}" + end + + # default knows everybody + # + def knows?(user) + return true if user.to_irc_user + end + + # We always allow logging in as the default user + def login(user, password) + return true + end + + # DefaultBotUser will check the default_perm after checking + # the global ones + # or on all channels if _chan_ is nil + # + def permit?(cmd, chan=nil) + allow = super(cmd, chan) + if allow.nil? && chan.nil? + allow = @default_perm.permit?(cmd) + end + return allow + end + + end + + # Returns the only instance of DefaultBotUserClass + # + def Auth.defaultbotuser + return DefaultBotUserClass.instance + end + + # This is the BotOwner: he can do everything + # + class BotOwnerClass < BotUser + + include Singleton + + def initialize + @login_by_mask = false + @autologin = true + super("owner") + end + + def permit?(cmd, chan=nil) + return true + end + + end + + # Returns the only instance of BotOwnerClass + # + def Auth.botowner + return BotOwnerClass.instance + end + + + class BotUser + # Check if the current BotUser is the default one + def default? + return DefaultBotUserClass === self + end + + # Check if the current BotUser is the owner + def owner? + return BotOwnerClass === self + end + end + + + # This is the ManagerClass singleton, used to manage + # Irc::User/Irc::Bot::Auth::BotUser connections and everything + # + class ManagerClass + + include Singleton + + attr_reader :maskdb + attr_reader :everyone + attr_reader :botowner + attr_reader :bot + + # The instance manages two <code>Hash</code>es: one that maps + # <code>Irc::User</code>s onto <code>BotUser</code>s, and the other that maps + # usernames onto <code>BotUser</code> + def initialize + @everyone = Auth::defaultbotuser + @botowner = Auth::botowner + bot_associate(nil) + end + + def bot_associate(bot) + raise "Cannot associate with a new bot! Save first" if defined?(@has_changes) && @has_changes + + reset_hashes + + # Associated bot + @bot = bot + + # This variable is set to true when there have been changes + # to the botusers list, so that we know when to save + @has_changes = false + end + + def set_changed + @has_changes = true + end + + def reset_changed + @has_changes = false + end + + def changed? + @has_changes + end + + # resets the hashes + def reset_hashes + @botusers = Hash.new + @maskdb = NetmaskDb.new + @allbotusers = Hash.new + [everyone, botowner].each do |x| + @allbotusers[x.username.to_sym] = x + end + end + + def load_array(ary, forced) + unless ary + warning "Tried to load an empty array" + return + end + raise "Won't load with unsaved changes" if @has_changes and not forced + reset_hashes + ary.each { |x| + raise TypeError, "#{x} should be a Hash" unless x.kind_of?(Hash) + u = x[:username] + unless include?(u) + create_botuser(u) + end + get_botuser(u).from_hash(x) + get_botuser(u).transient = false + } + @has_changes=false + end + + def save_array + @allbotusers.values.map { |x| + x.transient? ? nil : x.to_hash + }.compact + end + + # checks if we know about a certain BotUser username + def include?(botusername) + @allbotusers.has_key?(botusername.to_sym) + end + + # Maps <code>Irc::User</code> to BotUser + def irc_to_botuser(ircuser) + logged = @botusers[ircuser.to_irc_user] + return logged if logged + return autologin(ircuser) + end + + # creates a new BotUser + def create_botuser(name, password=nil) + n = BotUser.sanitize_username(name) + k = n.to_sym + raise "botuser #{n} exists" if include?(k) + bu = BotUser.new(n) + bu.password = password + @allbotusers[k] = bu + return bu + end + + # returns the botuser with name _name_ + def get_botuser(name) + @allbotusers.fetch(BotUser.sanitize_username(name).to_sym) + end + + # Logs Irc::User _user_ in to BotUser _botusername_ with password _pwd_ + # + # raises an error if _botusername_ is not a known BotUser username + # + # It is possible to autologin by Netmask, on request + # + def login(user, botusername, pwd=nil) + ircuser = user.to_irc_user + n = BotUser.sanitize_username(botusername) + k = n.to_sym + raise "No such BotUser #{n}" unless include?(k) + if @botusers.has_key?(ircuser) + return true if @botusers[ircuser].username == n + # TODO + # @botusers[ircuser].logout(ircuser) + end + bu = @allbotusers[k] + if bu.login(ircuser, pwd) + @botusers[ircuser] = bu + return true + end + return false + end + + # Tries to auto-login Irc::User _user_ by looking at the known botusers that allow autologin + # and trying to login without a password + # + def autologin(user) + ircuser = user.to_irc_user + debug "Trying to autologin #{ircuser}" + return @botusers[ircuser] if @botusers.has_key?(ircuser) + bu = maskdb.find(ircuser) + if bu + debug "trying #{bu}" + bu.login(ircuser) or raise '...what?!' + @botusers[ircuser] = bu + return bu + end + # Finally, create a transient if we're set to allow it + if @bot.config['auth.autouser'] + bu = create_transient_botuser(ircuser) + @botusers[ircuser] = bu + return bu + end + return everyone + end + + # Creates a new transient BotUser associated with Irc::User _user_, + # automatically logging him in. Note that transient botuser creation can + # fail, typically if we don't have the complete user netmask (e.g. for + # messages coming in from a linkbot) + # + def create_transient_botuser(user) + ircuser = user.to_irc_user + bu = everyone + begin + bu = BotUser.new(ircuser, :transient => true, :masks => ircuser) + bu.login(ircuser) + rescue + warning "failed to create transient for #{user}" + error $! + end + return bu + end + + # Logs out any Irc::User matching Irc::Netmask _m_ and logged in + # to a transient BotUser + # + def logout_transients(m) + debug "to check: #{@botusers.keys.join ' '}" + @botusers.keys.each do |iu| + debug "checking #{iu.fullform} against #{m.fullform}" + bu = @botusers[iu] + bu.transient? or next + iu.matches?(m) or next + @botusers.delete(iu).autologin = false + end + end + + # Makes transient BotUser _user_ into a permanent BotUser + # named _name_; if _user_ is an Irc::User, act on the transient + # BotUser (if any) it's logged in as + # + def make_permanent(user, name) + buname = BotUser.sanitize_username(name) + # TODO merge BotUser instead? + raise "there's already a BotUser called #{name}" if include?(buname) + + tuser = nil + case user + when String, Irc::User + tuser = irc_to_botuser(user) + when BotUser + tuser = user + else + raise TypeError, "sorry, don't know how to make #{user.class} into a permanent BotUser" + end + return nil unless tuser + raise TypeError, "#{tuser} is not transient" unless tuser.transient? + + tuser.make_permanent(buname) + @allbotusers[tuser.username.to_sym] = tuser + + return tuser + end + + # Checks if User _user_ can do _cmd_ on _chan_. + # + # Permission are checked in this order, until a true or false + # is returned: + # * associated BotUser on _chan_ + # * associated BotUser on all channels + # * everyone on _chan_ + # * everyone on all channels + # + def permit?(user, cmdtxt, channel=nil) + if user.class <= BotUser + botuser = user + else + botuser = irc_to_botuser(user) + end + cmd = cmdtxt.to_irc_auth_command + + chan = channel + case chan + when User + chan = "?" + when Channel + chan = chan.name + end + + allow = nil + + allow = botuser.permit?(cmd, chan) if chan + return allow unless allow.nil? + allow = botuser.permit?(cmd) + return allow unless allow.nil? + + unless botuser == everyone + allow = everyone.permit?(cmd, chan) if chan + return allow unless allow.nil? + allow = everyone.permit?(cmd) + return allow unless allow.nil? + end + + raise "Could not check permission for user #{user.inspect} to run #{cmdtxt.inspect} on #{chan.inspect}" + end + + # Checks if command _cmd_ is allowed to User _user_ on _chan_, optionally + # telling if the user is authorized + # + def allow?(cmdtxt, user, chan=nil) + if permit?(user, cmdtxt, chan) + return true + else + # cmds = cmdtxt.split('::') + # @bot.say chan, "you don't have #{cmds.last} (#{cmds.first}) permissions here" if chan + @bot.say chan, _("%{user}, you don't have '%{command}' permissions here") % + {:user=>user, :command=>cmdtxt} if chan + return false + end + end + + end + + # Returns the only instance of ManagerClass + # + def Auth.manager + return ManagerClass.instance + end + + end +end + + class User + + # A convenience method to automatically found the botuser + # associated with the receiver + # + def botuser + Irc::Bot::Auth.manager.irc_to_botuser(self) + end + end + +end diff --git a/lib/rbot/core/auth.rb b/lib/rbot/core/auth.rb index b0aa12c1..d6167535 100644 --- a/lib/rbot/core/auth.rb +++ b/lib/rbot/core/auth.rb @@ -1,968 +1,968 @@ -#-- vim:sw=2:et
-#++
-#
-# :title: rbot auth management from IRC
-#
-# Author:: Giuseppe "Oblomov" Bilotta <giuseppe.bilotta@gmail.com>
-# Copyright:: (C) 2006,2007 Giuseppe Bilotta
-# License:: GPL v2
-
-class AuthModule < CoreBotModule
-
- def initialize
- super
-
- # The namespace migration causes each Irc::Auth::PermissionSet to be
- # unrecoverable, and we have to rename their class name to
- # Irc::Bot::Auth::PermissionSet
- @registry.recovery = Proc.new { |val|
- patched = val.sub("o:\035Irc::Auth::PermissionSet", "o:\042Irc::Bot::Auth::PermissionSet")
- Marshal.restore(patched)
- }
-
- load_array(:default, true)
- debug "initialized auth. Botusers: #{@bot.auth.save_array.pretty_inspect}"
- end
-
- def save
- save_array
- end
-
- def save_array(key=:default)
- if @bot.auth.changed?
- @registry[key] = @bot.auth.save_array
- @bot.auth.reset_changed
- debug "saved botusers (#{key}): #{@registry[key].pretty_inspect}"
- end
- end
-
- def load_array(key=:default, forced=false)
- debug "loading botusers (#{key}): #{@registry[key].pretty_inspect}"
- @bot.auth.load_array(@registry[key], forced) if @registry.has_key?(key)
- end
-
- # The permission parameters accept arguments with the following syntax:
- # cmd_path... [on #chan .... | in here | in private]
- # This auxiliary method scans the array _ar_ to see if it matches
- # the given syntax: it expects + or - signs in front of _cmd_path_
- # elements when _setting_ = true
- #
- # It returns an array whose first element is the array of cmd_path,
- # the second element is an array of locations and third an array of
- # warnings occurred while parsing the strings
- #
- def parse_args(ar, setting)
- cmds = []
- locs = []
- warns = []
- doing_cmds = true
- next_must_be_chan = false
- want_more = false
- last_idx = 0
- ar.each_with_index { |x, i|
- if doing_cmds # parse cmd_path
- # check if the list is done
- if x == "on" or x == "in"
- doing_cmds = false
- next_must_be_chan = true if x == "on"
- next
- end
- if "+-".include?(x[0])
- warns << ArgumentError.new(_("please do not use + or - in front of command %{command} when resetting") % {:command => x}) unless setting
- else
- warns << ArgumentError.new(_("+ or - expected in front of %{string}") % {:string => x}) if setting
- end
- cmds << x
- else # parse locations
- if x[-1].chr == ','
- want_more = true
- else
- want_more = false
- end
- case next_must_be_chan
- when false
- locs << x.gsub(/^here$/,'_').gsub(/^private$/,'?')
- else
- warns << ArgumentError.new(_("'%{string}' doesn't look like a channel name") % {:string => x}) unless @bot.server.supports[:chantypes].include?(x[0])
- locs << x
- end
- unless want_more
- last_idx = i
- break
- end
- end
- }
- warns << _("trailing comma") if want_more
- warns << _("you probably forgot a comma") unless last_idx == ar.length - 1
- return cmds, locs, warns
- end
-
- def auth_edit_perm(m, params)
-
- setting = m.message.split[1] == "set"
- splits = params[:args]
-
- has_for = splits[-2] == "for"
- return usage(m) unless has_for
-
- begin
- user = @bot.auth.get_botuser(splits[-1].sub(/^all$/,"everyone"))
- rescue
- return m.reply(_("couldn't find botuser %{name}") % {:name => splits[-1]})
- end
- return m.reply(_("you can't change permissions for %{username}") % {:username => user.username}) if user.owner?
- splits.slice!(-2,2) if has_for
-
- cmds, locs, warns = parse_args(splits, setting)
- errs = warns.select { |w| w.kind_of?(Exception) }
-
- unless errs.empty?
- m.reply _("couldn't satisfy your request: %{errors}") % {:errors => errs.join(',')}
- return
- end
-
- if locs.empty?
- locs << "*"
- end
- begin
- locs.each { |loc|
- ch = loc
- if m.private?
- ch = "?" if loc == "_"
- else
- ch = m.target.to_s if loc == "_"
- end
- cmds.each { |setval|
- if setting
- val = setval[0].chr == '+'
- cmd = setval[1..-1]
- user.set_permission(cmd, val, ch)
- else
- cmd = setval
- user.reset_permission(cmd, ch)
- end
- }
- }
- rescue => e
- m.reply "something went wrong while trying to set the permissions"
- raise
- end
- @bot.auth.set_changed
- debug "user #{user} permissions changed"
- m.okay
- end
-
- def auth_view_perm(m, params)
- begin
- if params[:user].nil?
- user = get_botusername_for(m.source)
- return m.reply(_("you are owner, you can do anything")) if user.owner?
- else
- user = @bot.auth.get_botuser(params[:user].sub(/^all$/,"everyone"))
- return m.reply(_("owner can do anything")) if user.owner?
- end
- rescue
- return m.reply(_("couldn't find botuser %{name}") % {:name => params[:user]})
- end
- perm = user.perm
- str = []
- perm.each { |k, val|
- next if val.perm.empty?
- case k
- when :*
- str << _("on any channel: ")
- when :"?"
- str << _("in private: ")
- else
- str << _("on #{k}: ")
- end
- sub = []
- val.perm.each { |cmd, bool|
- sub << (bool ? "+" : "-")
- sub.last << cmd.to_s
- }
- str.last << sub.join(', ')
- }
- if str.empty?
- m.reply _("no permissions set for %{user}") % {:user => user.username}
- else
- m.reply _("permissions for %{user}:: %{permissions}") %
- { :user => user.username, :permissions => str.join('; ')}
- end
- end
-
- def auth_search_perm(m, p)
- pattern = Regexp.new(p[:pattern].to_s)
- results = @bot.plugins.maps.select { |k, v| k.match(pattern) }
- count = results.length
- max = @bot.config['send.max_lines']
- extra = (count > max ? _(". only %{max} will be shown") : "") % { :max => max }
- m.reply _("%{count} commands found matching %{pattern}%{extra}") % {
- :count => count, :pattern => pattern, :extra => extra
- }
- return if count == 0
- results[0,max].each { |cmd, hash|
- m.reply _("%{cmd}: %{perms}") % {
- :cmd => cmd,
- :perms => hash[:auth].join(", ")
- }
- }
- end
-
- def get_botuser_for(user)
- @bot.auth.irc_to_botuser(user)
- end
-
- def get_botusername_for(user)
- get_botuser_for(user).username
- end
-
- def welcome(user)
- _("welcome, %{user}") % {:user => get_botusername_for(user)}
- end
-
- def auth_auth(m, params)
- params[:botuser] = 'owner'
- auth_login(m,params)
- end
-
- def auth_login(m, params)
- begin
- case @bot.auth.login(m.source, params[:botuser], params[:password])
- when true
- m.reply welcome(m.source)
- @bot.auth.set_changed
- else
- m.reply _("sorry, can't do")
- end
- rescue => e
- m.reply _("couldn't login: %{exception}") % {:exception => e}
- raise
- end
- end
-
- def auth_autologin(m, params)
- u = do_autologin(m.source)
- if u.default?
- m.reply _("I couldn't find anything to let you login automatically")
- else
- m.reply welcome(m.source)
- end
- end
-
- def do_autologin(user)
- @bot.auth.autologin(user)
- end
-
- def auth_whoami(m, params)
- m.reply _("you are %{who}") % {
- :who => get_botusername_for(m.source).gsub(
- /^everyone$/, _("no one that I know")).gsub(
- /^owner$/, _("my boss"))
- }
- end
-
- def auth_whois(m, params)
- return auth_whoami(m, params) if !m.public?
- u = m.channel.users[params[:user]]
-
- return m.reply("I don't see anyone named '#{params[:user]}' here") unless u
-
- m.reply _("#{params[:user]} is %{who}") % {
- :who => get_botusername_for(u).gsub(
- /^everyone$/, _("no one that I know")).gsub(
- /^owner$/, _("my boss"))
- }
- end
-
- def help(cmd, topic="")
- case cmd
- when "login"
- return _("login [<botuser>] [<pass>]: logs in to the bot as botuser <botuser> with password <pass>. When using the full form, you must contact the bot in private. <pass> can be omitted if <botuser> allows login-by-mask and your netmask is among the known ones. if <botuser> is omitted too autologin will be attempted")
- when "whoami"
- return _("whoami: names the botuser you're linked to")
- when "who"
- return _("who is <user>: names the botuser <user> is linked to")
- when /^permission/
- case topic
- when "syntax"
- return _("a permission is specified as module::path::to::cmd; when you want to enable it, prefix it with +; when you want to disable it, prefix it with -; when using the +reset+ command, do not use any prefix")
- when "set", "reset", "[re]set", "(re)set"
- return _("permissions [re]set <permission> [in <channel>] for <user>: sets or resets the permissions for botuser <user> in channel <channel> (use ? to change the permissions for private addressing)")
- when "view"
- return _("permissions view [for <user>]: display the permissions for user <user>")
- when "searc"
- return _("permissions search <pattern>: display the permissions associated with the commands matching <pattern>")
- else
- return _("permission topics: syntax, (re)set, view, search")
- end
- when "user"
- case topic
- when "show"
- return _("user show <what> : shows info about the user; <what> can be any of autologin, login-by-mask, netmasks")
- when /^(en|dis)able/
- return _("user enable|disable <what> : turns on or off <what> (autologin, login-by-mask)")
- when "set"
- return _("user set password <blah> : sets the user password to <blah>; passwords can only contain upper and lowercase letters and numbers, and must be at least 4 characters long")
- when "add", "rm"
- return _("user add|rm netmask <mask> : adds/removes netmask <mask> from the list of netmasks known to the botuser you're linked to")
- when "reset"
- return _("user reset <what> : resets <what> to the default values. <what> can be +netmasks+ (the list will be emptied), +autologin+ or +login-by-mask+ (will be reset to the default value) or +password+ (a new one will be generated and you'll be told in private)")
- when "tell"
- return _("user tell <who> the password for <botuser> : contacts <who> in private to tell him/her the password for <botuser>")
- when "create"
- return _("user create <name> <password> : create botuser named <name> with password <password>. The password can be omitted, in which case a random one will be generated. The <name> should only contain alphanumeric characters and the underscore (_)")
- when "list"
- return _("user list : lists all the botusers")
- when "destroy"
- return _("user destroy <botuser> : destroys <botuser>. This function %{highlight}must%{highlight} be called in two steps. On the first call <botuser> is queued for destruction. On the second call, which must be in the form 'user confirm destroy <botuser>', the botuser will be destroyed. If you want to cancel the destruction, issue the command 'user cancel destroy <botuser>'") % {:highlight => Bold}
- else
- return _("user topics: show, enable|disable, add|rm netmask, set, reset, tell, create, list, destroy")
- end
- when "auth"
- return _("auth <masterpassword>: log in as the bot owner; other commands: login, whoami, permission syntax, permissions [re]set, permissions view, user, meet, hello")
- when "meet"
- return _("meet <nick> [as <user>]: creates a bot user for nick, calling it user (defaults to the nick itself)")
- when "hello"
- return _("hello: creates a bot user for the person issuing the command")
- else
- return _("auth commands: auth, login, whoami, who, permission[s], user, meet, hello")
- end
- end
-
- def need_args(cmd)
- _("sorry, I need more arguments to %{command}") % {:command => cmd}
- end
-
- def not_args(cmd, *stuff)
- _("I can only %{command} these: %{arguments}") %
- {:command => cmd, :arguments => stuff.join(', ')}
- end
-
- def set_prop(botuser, prop, val)
- k = prop.to_s.gsub("-","_")
- botuser.send( (k + "=").to_sym, val)
- if prop == :password and botuser == @bot.auth.botowner
- @bot.config.items[:'auth.password'].set_string(@bot.auth.botowner.password)
- end
- end
-
- def reset_prop(botuser, prop)
- k = prop.to_s.gsub("-","_")
- botuser.send( ("reset_"+k).to_sym)
- end
-
- def ask_bool_prop(botuser, prop)
- k = prop.to_s.gsub("-","_")
- botuser.send( (k + "?").to_sym)
- end
-
- def auth_manage_user(m, params)
- splits = params[:data]
-
- cmd = splits.first
- return auth_whoami(m, params) if cmd.nil?
-
- botuser = get_botuser_for(m.source)
- # By default, we do stuff on the botuser the irc user is bound to
- butarget = botuser
-
- has_for = splits[-2] == "for"
- if has_for
- butarget = @bot.auth.get_botuser(splits[-1]) rescue nil
- return m.reply(_("no such bot user %{user}") % {:user => splits[-1]}) unless butarget
- splits.slice!(-2,2)
- end
- return m.reply(_("you can't mess with %{user}") % {:user => butarget.username}) if butarget.owner? && botuser != butarget
-
- bools = [:autologin, :"login-by-mask"]
- can_set = [:password]
- can_addrm = [:netmasks]
- can_reset = bools + can_set + can_addrm
- can_show = can_reset + ["perms"]
-
- begin
- case cmd.to_sym
-
- when :show
- return m.reply(_("you can't see the properties of %{user}") %
- {:user => butarget.username}) if botuser != butarget &&
- !botuser.permit?("auth::show::other")
-
- case splits[1]
- when nil, "all"
- props = can_reset
- when "password"
- if botuser != butarget
- return m.reply(_("no way I'm telling you the master password!")) if butarget == @bot.auth.botowner
- return m.reply(_("you can't ask for someone else's password"))
- end
- return m.reply(_("c'mon, you can't be asking me seriously to tell you the password in public!")) if m.public?
- return m.reply(_("the password for %{user} is %{password}") %
- { :user => butarget.username, :password => butarget.password })
- else
- props = splits[1..-1]
- end
-
- str = []
-
- props.each { |arg|
- k = arg.to_sym
- next if k == :password
- case k
- when *bools
- if ask_bool_prop(butarget, k)
- str << _("can %{action}") % {:action => k}
- else
- str << _("can not %{action}") % {:action => k}
- end
- when :netmasks
- if butarget.netmasks.empty?
- str << _("knows no netmasks")
- else
- str << _("knows %{netmasks}") % {:netmasks => butarget.netmasks.join(", ")}
- end
- end
- }
- return m.reply("#{butarget.username} #{str.join('; ')}")
-
- when :enable, :disable
- return m.reply(_("you can't change the default user")) if butarget.default? && !botuser.permit?("auth::edit::other::default")
- return m.reply(_("you can't edit %{user}") % {:user => butarget.username}) if butarget != botuser && !botuser.permit?("auth::edit::other")
-
- return m.reply(need_args(cmd)) unless splits[1]
- things = []
- skipped = []
- splits[1..-1].each { |a|
- arg = a.to_sym
- if bools.include?(arg)
- set_prop(butarget, arg, cmd.to_sym == :enable)
- things << a
- else
- skipped << a
- end
- }
-
- m.reply(_("I ignored %{things} because %{reason}") % {
- :things => skipped.join(', '),
- :reason => not_args(cmd, *bools)}) unless skipped.empty?
- if things.empty?
- m.reply _("I haven't changed anything")
- else
- @bot.auth.set_changed
- return auth_manage_user(m, {:data => ["show"] + things + ["for", butarget.username] })
- end
-
- when :set
- return m.reply(_("you can't change the default user")) if
- butarget.default? && !botuser.permit?("auth::edit::default")
- return m.reply(_("you can't edit %{user}") % {:user=>butarget.username}) if
- butarget != botuser && !botuser.permit?("auth::edit::other")
-
- return m.reply(need_args(cmd)) unless splits[1]
- arg = splits[1].to_sym
- return m.reply(not_args(cmd, *can_set)) unless can_set.include?(arg)
- argarg = splits[2]
- return m.reply(need_args([cmd, splits[1]].join(" "))) unless argarg
- if arg == :password && m.public?
- return m.reply(_("is that a joke? setting the password in public?"))
- end
- set_prop(butarget, arg, argarg)
- @bot.auth.set_changed
- auth_manage_user(m, {:data => ["show", arg.to_s, "for", butarget.username] })
-
- when :reset
- return m.reply(_("you can't change the default user")) if
- butarget.default? && !botuser.permit?("auth::edit::default")
- return m.reply(_("you can't edit %{user}") % {:user=>butarget.username}) if
- butarget != botuser && !botuser.permit?("auth::edit::other")
-
- return m.reply(need_args(cmd)) unless splits[1]
- things = []
- skipped = []
- splits[1..-1].each { |a|
- arg = a.to_sym
- if can_reset.include?(arg)
- reset_prop(butarget, arg)
- things << a
- else
- skipped << a
- end
- }
-
- m.reply(_("I ignored %{things} because %{reason}") %
- { :things => skipped.join(', '),
- :reason => not_args(cmd, *can_reset)}) unless skipped.empty?
- if things.empty?
- m.reply _("I haven't changed anything")
- else
- @bot.auth.set_changed
- @bot.say(m.source, _("the password for %{user} is now %{password}") %
- {:user => butarget.username, :password => butarget.password}) if
- things.include?("password")
- return auth_manage_user(m, {:data => (["show"] + things - ["password"]) + ["for", butarget.username]})
- end
-
- when :add, :rm, :remove, :del, :delete
- return m.reply(_("you can't change the default user")) if
- butarget.default? && !botuser.permit?("auth::edit::default")
- return m.reply(_("you can't edit %{user}") % {:user => butarget.username}) if
- butarget != botuser && !botuser.permit?("auth::edit::other")
-
- arg = splits[1]
- if arg.nil? or arg !~ /netmasks?/ or splits[2].nil?
- return m.reply(_("I can only add/remove netmasks. See +help user add+ for more instructions"))
- end
-
- method = cmd.to_sym == :add ? :add_netmask : :delete_netmask
-
- failed = []
-
- splits[2..-1].each { |mask|
- begin
- butarget.send(method, mask.to_irc_netmask(:server => @bot.server))
- rescue => e
- debug "failed with #{e.message}"
- debug e.backtrace.join("\n")
- failed << mask
- end
- }
- m.reply "I failed to #{cmd} #{failed.join(', ')}" unless failed.empty?
- @bot.auth.set_changed
- return auth_manage_user(m, {:data => ["show", "netmasks", "for", butarget.username] })
-
- else
- m.reply _("sorry, I don't know how to %{request}") % {:request => m.message}
- end
- rescue => e
- m.reply _("couldn't %{cmd}: %{exception}") % {:cmd => cmd, :exception => e}
- end
- end
-
- def auth_meet(m, params)
- nick = params[:nick]
- if !nick
- # we are actually responding to a 'hello' command
- unless m.botuser.transient?
- m.reply @bot.lang.get('hello_X') % m.botuser
- return
- end
- nick = m.sourcenick
- irc_user = m.source
- else
- # m.channel is always an Irc::Channel because the command is either
- # public-only 'meet' or private/public 'hello' which was handled by
- # the !nick case, so this shouldn't fail
- irc_user = m.channel.users[nick]
- return m.reply("I don't see anyone named '#{nick}' here") unless irc_user
- end
- # BotUser name
- buname = params[:user] || nick
- begin
- call_event(:botuser,:pre_perm, {:irc_user => irc_user, :bot_user => buname})
- met = @bot.auth.make_permanent(irc_user, buname)
- @bot.auth.set_changed
- call_event(:botuser,:post_perm, {:irc_user => irc_user, :bot_user => buname})
- m.reply @bot.lang.get('hello_X') % met
- @bot.say nick, _("you are now registered as %{buname}. I created a random password for you : %{pass} and you can change it at any time by telling me 'user set password <password>' in private" % {
- :buname => buname,
- :pass => met.password
- })
- rescue RuntimeError
- # or can this happen for other cases too?
- # TODO autologin if forced
- m.reply _("but I already know %{buname}" % {:buname => buname})
- rescue => e
- m.reply _("I had problems meeting %{nick}: %{e}" % { :nick => nick, :e => e })
- end
- end
-
- def auth_tell_password(m, params)
- user = params[:user]
- begin
- botuser = @bot.auth.get_botuser(params[:botuser])
- rescue
- return m.reply(_("couldn't find botuser %{user}") % {:user => params[:botuser]})
- end
- m.reply(_("I'm not telling the master password to anyway, pal")) if botuser == @bot.auth.botowner
- msg = _("the password for botuser %{user} is %{password}") %
- {:user => botuser.username, :password => botuser.password}
- @bot.say user, msg
- @bot.say m.source, _("I told %{user} that %{message}") % {:user => user, :message => msg}
- end
-
- def auth_create_user(m, params)
- name = params[:name]
- password = params[:password]
- return m.reply(_("are you nuts, creating a botuser with a publicly known password?")) if m.public? and not password.nil?
- begin
- bu = @bot.auth.create_botuser(name, password)
- @bot.auth.set_changed
- rescue => e
- m.reply(_("failed to create %{user}: %{exception}") % {:user => name, :exception => e})
- debug e.inspect + "\n" + e.backtrace.join("\n")
- return
- end
- m.reply(_("created botuser %{user}") % {:user => bu.username})
- end
-
- def auth_list_users(m, params)
- # TODO name regexp to filter results
- list = @bot.auth.save_array.inject([]) { |list, x| ['everyone', 'owner'].include?(x[:username]) ? list : list << x[:username] }
- if defined?(@destroy_q)
- list.map! { |x|
- @destroy_q.include?(x) ? x + _(" (queued for destruction)") : x
- }
- end
- return m.reply(_("I have no botusers other than the default ones")) if list.empty?
- return m.reply(n_("botuser: %{list}", "botusers: %{list}", list.length) %
- {:list => list.join(', ')})
- end
-
- def auth_destroy_user(m, params)
- @destroy_q = [] unless defined?(@destroy_q)
- buname = params[:name]
- return m.reply(_("You can't destroy %{user}") % {:user => buname}) if
- ["everyone", "owner"].include?(buname)
- mod = params[:modifier].to_sym rescue nil
-
- buser_array = @bot.auth.save_array
- buser_hash = buser_array.inject({}) { |h, u|
- h[u[:username]] = u
- h
- }
-
- return m.reply(_("no such botuser %{user}") % {:user=>buname}) unless
- buser_hash.keys.include?(buname)
-
- case mod
- when :cancel
- if @destroy_q.include?(buname)
- @destroy_q.delete(buname)
- m.reply(_("%{user} removed from the destruction queue") % {:user=>buname})
- else
- m.reply(_("%{user} was not queued for destruction") % {:user=>buname})
- end
- return
- when nil
- if @destroy_q.include?(buname)
- return m.reply(_("%{user} already queued for destruction, use %{highlight}user confirm destroy %{user}%{highlight} to destroy it") % {:user=>buname, :highlight=>Bold})
- else
- @destroy_q << buname
- return m.reply(_("%{user} queued for destruction, use %{highlight}user confirm destroy %{user}%{highlight} to destroy it") % {:user=>buname, :highlight=>Bold})
- end
- when :confirm
- begin
- return m.reply(_("%{user} is not queued for destruction yet") %
- {:user=>buname}) unless @destroy_q.include?(buname)
- buser_array.delete_if { |u|
- u[:username] == buname
- }
- @destroy_q.delete(buname)
- @bot.auth.load_array(buser_array, true)
- @bot.auth.set_changed
- rescue => e
- return m.reply(_("failed: %{exception}") % {:exception => e})
- end
- return m.reply(_("botuser %{user} destroyed") % {:user => buname})
- end
- end
-
- def auth_copy_ren_user(m, params)
- source = Auth::BotUser.sanitize_username(params[:source])
- dest = Auth::BotUser.sanitize_username(params[:dest])
- return m.reply(_("please don't touch the default users")) unless
- (["everyone", "owner"] & [source, dest]).empty?
-
- buser_array = @bot.auth.save_array
- buser_hash = buser_array.inject({}) { |h, u|
- h[u[:username]] = u
- h
- }
-
- return m.reply(_("no such botuser %{source}") % {:source=>source}) unless
- buser_hash.keys.include?(source)
- return m.reply(_("botuser %{dest} exists already") % {:dest=>dest}) if
- buser_hash.keys.include?(dest)
-
- copying = m.message.split[1] == "copy"
- begin
- if copying
- h = {}
- buser_hash[source].each { |k, val|
- h[k] = val.dup
- }
- else
- h = buser_hash[source]
- end
- h[:username] = dest
- buser_array << h if copying
-
- @bot.auth.load_array(buser_array, true)
- @bot.auth.set_changed
- call_event(:botuser, copying ? :copy : :rename, :source => source, :dest => dest)
- rescue => e
- return m.reply(_("failed: %{exception}") % {:exception=>e})
- end
- if copying
- m.reply(_("botuser %{source} copied to %{dest}") %
- {:source=>source, :dest=>dest})
- else
- m.reply(_("botuser %{source} renamed to %{dest}") %
- {:source=>source, :dest=>dest})
- end
-
- end
-
- def auth_export(m, params)
-
- exportfile = "#{@bot.botclass}/new-auth.users"
-
- what = params[:things]
-
- has_to = what[-2] == "to"
- if has_to
- exportfile = "#{@bot.botclass}/#{what[-1]}"
- what.slice!(-2,2)
- end
-
- what.delete("all")
-
- m.reply _("selecting data to export ...")
-
- buser_array = @bot.auth.save_array
- buser_hash = buser_array.inject({}) { |h, u|
- h[u[:username]] = u
- h
- }
-
- if what.empty?
- we_want = buser_hash
- else
- we_want = buser_hash.delete_if { |key, val|
- not what.include?(key)
- }
- end
-
- m.reply _("preparing data for export ...")
- begin
- yaml_hash = {}
- we_want.each { |k, val|
- yaml_hash[k] = {}
- val.each { |kk, v|
- case kk
- when :username
- next
- when :netmasks
- yaml_hash[k][kk] = []
- v.each { |nm|
- yaml_hash[k][kk] << {
- :fullform => nm.fullform,
- :casemap => nm.casemap.to_s
- }
- }
- else
- yaml_hash[k][kk] = v
- end
- }
- }
- rescue => e
- m.reply _("failed to prepare data: %{exception}") % {:exception=>e}
- debug e.backtrace.dup.unshift(e.inspect).join("\n")
- return
- end
-
- m.reply _("exporting to %{file} ...") % {:file=>exportfile}
- begin
- # m.reply yaml_hash.inspect
- File.open(exportfile, "w") do |file|
- file.puts YAML::dump(yaml_hash)
- end
- rescue => e
- m.reply _("failed to export users: %{exception}") % {:exception=>e}
- debug e.backtrace.dup.unshift(e.inspect).join("\n")
- return
- end
- m.reply _("done")
- end
-
- def auth_import(m, params)
-
- importfile = "#{@bot.botclass}/new-auth.users"
-
- what = params[:things]
-
- has_from = what[-2] == "from"
- if has_from
- importfile = "#{@bot.botclass}/#{what[-1]}"
- what.slice!(-2,2)
- end
-
- what.delete("all")
-
- m.reply _("reading %{file} ...") % {:file=>importfile}
- begin
- yaml_hash = YAML::load_file(importfile)
- rescue => e
- m.reply _("failed to import from: %{exception}") % {:exception=>e}
- debug e.backtrace.dup.unshift(e.inspect).join("\n")
- return
- end
-
- # m.reply yaml_hash.inspect
-
- m.reply _("selecting data to import ...")
-
- if what.empty?
- we_want = yaml_hash
- else
- we_want = yaml_hash.delete_if { |key, val|
- not what.include?(key)
- }
- end
-
- m.reply _("parsing data from import ...")
-
- buser_hash = {}
-
- begin
- yaml_hash.each { |k, val|
- buser_hash[k] = { :username => k }
- val.each { |kk, v|
- case kk
- when :netmasks
- buser_hash[k][kk] = []
- v.each { |nm|
- buser_hash[k][kk] << nm[:fullform].to_irc_netmask(:casemap => nm[:casemap].to_irc_casemap).to_irc_netmask(:server => @bot.server)
- }
- else
- buser_hash[k][kk] = v
- end
- }
- }
- rescue => e
- m.reply _("failed to parse data: %{exception}") % {:exception=>e}
- debug e.backtrace.dup.unshift(e.inspect).join("\n")
- return
- end
-
- # m.reply buser_hash.inspect
-
- org_buser_array = @bot.auth.save_array
- org_buser_hash = org_buser_array.inject({}) { |h, u|
- h[u[:username]] = u
- h
- }
-
- # TODO we may want to do a(n optional) key-by-key merge
- #
- org_buser_hash.merge!(buser_hash)
- new_buser_array = org_buser_hash.values
- @bot.auth.load_array(new_buser_array, true)
- @bot.auth.set_changed
-
- m.reply _("done")
- end
-
-end
-
-auth = AuthModule.new
-
-auth.map "user export *things",
- :action => 'auth_export',
- :defaults => { :things => ['all'] },
- :auth_path => ':manage:fedex:'
-
-auth.map "user import *things",
- :action => 'auth_import',
- :auth_path => ':manage:fedex:'
-
-auth.map "user create :name :password",
- :action => 'auth_create_user',
- :defaults => {:password => nil},
- :auth_path => ':manage:'
-
-auth.map "user [:modifier] destroy :name",
- :action => 'auth_destroy_user',
- :requirements => { :modifier => /^(cancel|confirm)?$/ },
- :defaults => { :modifier => '' },
- :auth_path => ':manage::destroy!'
-
-auth.map "user copy :source [to] :dest",
- :action => 'auth_copy_ren_user',
- :auth_path => ':manage:'
-
-auth.map "user rename :source [to] :dest",
- :action => 'auth_copy_ren_user',
- :auth_path => ':manage:'
-
-auth.map "meet :nick [as :user]",
- :action => 'auth_meet',
- :auth_path => 'user::manage', :private => false
-
-auth.map "hello",
- :action => 'auth_meet',
- :auth_path => 'user::manage::meet'
-
-auth.default_auth("user::manage", false)
-auth.default_auth("user::manage::meet::hello", true)
-
-auth.map "user tell :user the password for :botuser",
- :action => 'auth_tell_password',
- :auth_path => ':manage:'
-
-auth.map "user list",
- :action => 'auth_list_users',
- :auth_path => '::'
-
-auth.map "user *data",
- :action => 'auth_manage_user'
-
-auth.default_auth("user", true)
-auth.default_auth("edit::other", false)
-
-auth.map "whoami",
- :action => 'auth_whoami',
- :auth_path => '!*!'
-
-auth.map "who is :user",
- :action => 'auth_whois',
- :auth_path => '!*!'
-
-auth.map "auth :password",
- :action => 'auth_auth',
- :public => false,
- :auth_path => '!login!'
-
-auth.map "login :botuser :password",
- :action => 'auth_login',
- :public => false,
- :defaults => { :password => nil },
- :auth_path => '!login!'
-
-auth.map "login :botuser",
- :action => 'auth_login',
- :auth_path => '!login!'
-
-auth.map "login",
- :action => 'auth_autologin',
- :auth_path => '!login!'
-
-auth.map "permissions set *args",
- :action => 'auth_edit_perm',
- :auth_path => ':edit::set:'
-
-auth.map "permissions reset *args",
- :action => 'auth_edit_perm',
- :auth_path => ':edit::set:'
-
-auth.map "permissions view [for :user]",
- :action => 'auth_view_perm',
- :auth_path => '::'
-
-auth.map "permissions search *pattern",
- :action => 'auth_search_perm',
- :auth_path => '::'
-
-auth.default_auth('*', false)
-
+#-- vim:sw=2:et +#++ +# +# :title: rbot auth management from IRC +# +# Author:: Giuseppe "Oblomov" Bilotta <giuseppe.bilotta@gmail.com> +# Copyright:: (C) 2006,2007 Giuseppe Bilotta +# License:: GPL v2 + +class AuthModule < CoreBotModule + + def initialize + super + + # The namespace migration causes each Irc::Auth::PermissionSet to be + # unrecoverable, and we have to rename their class name to + # Irc::Bot::Auth::PermissionSet + @registry.recovery = Proc.new { |val| + patched = val.sub("o:\035Irc::Auth::PermissionSet", "o:\042Irc::Bot::Auth::PermissionSet") + Marshal.restore(patched) + } + + load_array(:default, true) + debug "initialized auth. Botusers: #{@bot.auth.save_array.pretty_inspect}" + end + + def save + save_array + end + + def save_array(key=:default) + if @bot.auth.changed? + @registry[key] = @bot.auth.save_array + @bot.auth.reset_changed + debug "saved botusers (#{key}): #{@registry[key].pretty_inspect}" + end + end + + def load_array(key=:default, forced=false) + debug "loading botusers (#{key}): #{@registry[key].pretty_inspect}" + @bot.auth.load_array(@registry[key], forced) if @registry.has_key?(key) + end + + # The permission parameters accept arguments with the following syntax: + # cmd_path... [on #chan .... | in here | in private] + # This auxiliary method scans the array _ar_ to see if it matches + # the given syntax: it expects + or - signs in front of _cmd_path_ + # elements when _setting_ = true + # + # It returns an array whose first element is the array of cmd_path, + # the second element is an array of locations and third an array of + # warnings occurred while parsing the strings + # + def parse_args(ar, setting) + cmds = [] + locs = [] + warns = [] + doing_cmds = true + next_must_be_chan = false + want_more = false + last_idx = 0 + ar.each_with_index { |x, i| + if doing_cmds # parse cmd_path + # check if the list is done + if x == "on" or x == "in" + doing_cmds = false + next_must_be_chan = true if x == "on" + next + end + if "+-".include?(x[0]) + warns << ArgumentError.new(_("please do not use + or - in front of command %{command} when resetting") % {:command => x}) unless setting + else + warns << ArgumentError.new(_("+ or - expected in front of %{string}") % {:string => x}) if setting + end + cmds << x + else # parse locations + if x[-1].chr == ',' + want_more = true + else + want_more = false + end + case next_must_be_chan + when false + locs << x.gsub(/^here$/,'_').gsub(/^private$/,'?') + else + warns << ArgumentError.new(_("'%{string}' doesn't look like a channel name") % {:string => x}) unless @bot.server.supports[:chantypes].include?(x[0]) + locs << x + end + unless want_more + last_idx = i + break + end + end + } + warns << _("trailing comma") if want_more + warns << _("you probably forgot a comma") unless last_idx == ar.length - 1 + return cmds, locs, warns + end + + def auth_edit_perm(m, params) + + setting = m.message.split[1] == "set" + splits = params[:args] + + has_for = splits[-2] == "for" + return usage(m) unless has_for + + begin + user = @bot.auth.get_botuser(splits[-1].sub(/^all$/,"everyone")) + rescue + return m.reply(_("couldn't find botuser %{name}") % {:name => splits[-1]}) + end + return m.reply(_("you can't change permissions for %{username}") % {:username => user.username}) if user.owner? + splits.slice!(-2,2) if has_for + + cmds, locs, warns = parse_args(splits, setting) + errs = warns.select { |w| w.kind_of?(Exception) } + + unless errs.empty? + m.reply _("couldn't satisfy your request: %{errors}") % {:errors => errs.join(',')} + return + end + + if locs.empty? + locs << "*" + end + begin + locs.each { |loc| + ch = loc + if m.private? + ch = "?" if loc == "_" + else + ch = m.target.to_s if loc == "_" + end + cmds.each { |setval| + if setting + val = setval[0].chr == '+' + cmd = setval[1..-1] + user.set_permission(cmd, val, ch) + else + cmd = setval + user.reset_permission(cmd, ch) + end + } + } + rescue => e + m.reply "something went wrong while trying to set the permissions" + raise + end + @bot.auth.set_changed + debug "user #{user} permissions changed" + m.okay + end + + def auth_view_perm(m, params) + begin + if params[:user].nil? + user = get_botusername_for(m.source) + return m.reply(_("you are owner, you can do anything")) if user.owner? + else + user = @bot.auth.get_botuser(params[:user].sub(/^all$/,"everyone")) + return m.reply(_("owner can do anything")) if user.owner? + end + rescue + return m.reply(_("couldn't find botuser %{name}") % {:name => params[:user]}) + end + perm = user.perm + str = [] + perm.each { |k, val| + next if val.perm.empty? + case k + when :* + str << _("on any channel: ") + when :"?" + str << _("in private: ") + else + str << _("on #{k}: ") + end + sub = [] + val.perm.each { |cmd, bool| + sub << (bool ? "+" : "-") + sub.last << cmd.to_s + } + str.last << sub.join(', ') + } + if str.empty? + m.reply _("no permissions set for %{user}") % {:user => user.username} + else + m.reply _("permissions for %{user}:: %{permissions}") % + { :user => user.username, :permissions => str.join('; ')} + end + end + + def auth_search_perm(m, p) + pattern = Regexp.new(p[:pattern].to_s) + results = @bot.plugins.maps.select { |k, v| k.match(pattern) } + count = results.length + max = @bot.config['send.max_lines'] + extra = (count > max ? _(". only %{max} will be shown") : "") % { :max => max } + m.reply _("%{count} commands found matching %{pattern}%{extra}") % { + :count => count, :pattern => pattern, :extra => extra + } + return if count == 0 + results[0,max].each { |cmd, hash| + m.reply _("%{cmd}: %{perms}") % { + :cmd => cmd, + :perms => hash[:auth].join(", ") + } + } + end + + def get_botuser_for(user) + @bot.auth.irc_to_botuser(user) + end + + def get_botusername_for(user) + get_botuser_for(user).username + end + + def welcome(user) + _("welcome, %{user}") % {:user => get_botusername_for(user)} + end + + def auth_auth(m, params) + params[:botuser] = 'owner' + auth_login(m,params) + end + + def auth_login(m, params) + begin + case @bot.auth.login(m.source, params[:botuser], params[:password]) + when true + m.reply welcome(m.source) + @bot.auth.set_changed + else + m.reply _("sorry, can't do") + end + rescue => e + m.reply _("couldn't login: %{exception}") % {:exception => e} + raise + end + end + + def auth_autologin(m, params) + u = do_autologin(m.source) + if u.default? + m.reply _("I couldn't find anything to let you login automatically") + else + m.reply welcome(m.source) + end + end + + def do_autologin(user) + @bot.auth.autologin(user) + end + + def auth_whoami(m, params) + m.reply _("you are %{who}") % { + :who => get_botusername_for(m.source).gsub( + /^everyone$/, _("no one that I know")).gsub( + /^owner$/, _("my boss")) + } + end + + def auth_whois(m, params) + return auth_whoami(m, params) if !m.public? + u = m.channel.users[params[:user]] + + return m.reply("I don't see anyone named '#{params[:user]}' here") unless u + + m.reply _("#{params[:user]} is %{who}") % { + :who => get_botusername_for(u).gsub( + /^everyone$/, _("no one that I know")).gsub( + /^owner$/, _("my boss")) + } + end + + def help(cmd, topic="") + case cmd + when "login" + return _("login [<botuser>] [<pass>]: logs in to the bot as botuser <botuser> with password <pass>. When using the full form, you must contact the bot in private. <pass> can be omitted if <botuser> allows login-by-mask and your netmask is among the known ones. if <botuser> is omitted too autologin will be attempted") + when "whoami" + return _("whoami: names the botuser you're linked to") + when "who" + return _("who is <user>: names the botuser <user> is linked to") + when /^permission/ + case topic + when "syntax" + return _("a permission is specified as module::path::to::cmd; when you want to enable it, prefix it with +; when you want to disable it, prefix it with -; when using the +reset+ command, do not use any prefix") + when "set", "reset", "[re]set", "(re)set" + return _("permissions [re]set <permission> [in <channel>] for <user>: sets or resets the permissions for botuser <user> in channel <channel> (use ? to change the permissions for private addressing)") + when "view" + return _("permissions view [for <user>]: display the permissions for user <user>") + when "searc" + return _("permissions search <pattern>: display the permissions associated with the commands matching <pattern>") + else + return _("permission topics: syntax, (re)set, view, search") + end + when "user" + case topic + when "show" + return _("user show <what> : shows info about the user; <what> can be any of autologin, login-by-mask, netmasks") + when /^(en|dis)able/ + return _("user enable|disable <what> : turns on or off <what> (autologin, login-by-mask)") + when "set" + return _("user set password <blah> : sets the user password to <blah>; passwords can only contain upper and lowercase letters and numbers, and must be at least 4 characters long") + when "add", "rm" + return _("user add|rm netmask <mask> : adds/removes netmask <mask> from the list of netmasks known to the botuser you're linked to") + when "reset" + return _("user reset <what> : resets <what> to the default values. <what> can be +netmasks+ (the list will be emptied), +autologin+ or +login-by-mask+ (will be reset to the default value) or +password+ (a new one will be generated and you'll be told in private)") + when "tell" + return _("user tell <who> the password for <botuser> : contacts <who> in private to tell him/her the password for <botuser>") + when "create" + return _("user create <name> <password> : create botuser named <name> with password <password>. The password can be omitted, in which case a random one will be generated. The <name> should only contain alphanumeric characters and the underscore (_)") + when "list" + return _("user list : lists all the botusers") + when "destroy" + return _("user destroy <botuser> : destroys <botuser>. This function %{highlight}must%{highlight} be called in two steps. On the first call <botuser> is queued for destruction. On the second call, which must be in the form 'user confirm destroy <botuser>', the botuser will be destroyed. If you want to cancel the destruction, issue the command 'user cancel destroy <botuser>'") % {:highlight => Bold} + else + return _("user topics: show, enable|disable, add|rm netmask, set, reset, tell, create, list, destroy") + end + when "auth" + return _("auth <masterpassword>: log in as the bot owner; other commands: login, whoami, permission syntax, permissions [re]set, permissions view, user, meet, hello") + when "meet" + return _("meet <nick> [as <user>]: creates a bot user for nick, calling it user (defaults to the nick itself)") + when "hello" + return _("hello: creates a bot user for the person issuing the command") + else + return _("auth commands: auth, login, whoami, who, permission[s], user, meet, hello") + end + end + + def need_args(cmd) + _("sorry, I need more arguments to %{command}") % {:command => cmd} + end + + def not_args(cmd, *stuff) + _("I can only %{command} these: %{arguments}") % + {:command => cmd, :arguments => stuff.join(', ')} + end + + def set_prop(botuser, prop, val) + k = prop.to_s.gsub("-","_") + botuser.send( (k + "=").to_sym, val) + if prop == :password and botuser == @bot.auth.botowner + @bot.config.items[:'auth.password'].set_string(@bot.auth.botowner.password) + end + end + + def reset_prop(botuser, prop) + k = prop.to_s.gsub("-","_") + botuser.send( ("reset_"+k).to_sym) + end + + def ask_bool_prop(botuser, prop) + k = prop.to_s.gsub("-","_") + botuser.send( (k + "?").to_sym) + end + + def auth_manage_user(m, params) + splits = params[:data] + + cmd = splits.first + return auth_whoami(m, params) if cmd.nil? + + botuser = get_botuser_for(m.source) + # By default, we do stuff on the botuser the irc user is bound to + butarget = botuser + + has_for = splits[-2] == "for" + if has_for + butarget = @bot.auth.get_botuser(splits[-1]) rescue nil + return m.reply(_("no such bot user %{user}") % {:user => splits[-1]}) unless butarget + splits.slice!(-2,2) + end + return m.reply(_("you can't mess with %{user}") % {:user => butarget.username}) if butarget.owner? && botuser != butarget + + bools = [:autologin, :"login-by-mask"] + can_set = [:password] + can_addrm = [:netmasks] + can_reset = bools + can_set + can_addrm + can_show = can_reset + ["perms"] + + begin + case cmd.to_sym + + when :show + return m.reply(_("you can't see the properties of %{user}") % + {:user => butarget.username}) if botuser != butarget && + !botuser.permit?("auth::show::other") + + case splits[1] + when nil, "all" + props = can_reset + when "password" + if botuser != butarget + return m.reply(_("no way I'm telling you the master password!")) if butarget == @bot.auth.botowner + return m.reply(_("you can't ask for someone else's password")) + end + return m.reply(_("c'mon, you can't be asking me seriously to tell you the password in public!")) if m.public? + return m.reply(_("the password for %{user} is %{password}") % + { :user => butarget.username, :password => butarget.password }) + else + props = splits[1..-1] + end + + str = [] + + props.each { |arg| + k = arg.to_sym + next if k == :password + case k + when *bools + if ask_bool_prop(butarget, k) + str << _("can %{action}") % {:action => k} + else + str << _("can not %{action}") % {:action => k} + end + when :netmasks + if butarget.netmasks.empty? + str << _("knows no netmasks") + else + str << _("knows %{netmasks}") % {:netmasks => butarget.netmasks.join(", ")} + end + end + } + return m.reply("#{butarget.username} #{str.join('; ')}") + + when :enable, :disable + return m.reply(_("you can't change the default user")) if butarget.default? && !botuser.permit?("auth::edit::other::default") + return m.reply(_("you can't edit %{user}") % {:user => butarget.username}) if butarget != botuser && !botuser.permit?("auth::edit::other") + + return m.reply(need_args(cmd)) unless splits[1] + things = [] + skipped = [] + splits[1..-1].each { |a| + arg = a.to_sym + if bools.include?(arg) + set_prop(butarget, arg, cmd.to_sym == :enable) + things << a + else + skipped << a + end + } + + m.reply(_("I ignored %{things} because %{reason}") % { + :things => skipped.join(', '), + :reason => not_args(cmd, *bools)}) unless skipped.empty? + if things.empty? + m.reply _("I haven't changed anything") + else + @bot.auth.set_changed + return auth_manage_user(m, {:data => ["show"] + things + ["for", butarget.username] }) + end + + when :set + return m.reply(_("you can't change the default user")) if + butarget.default? && !botuser.permit?("auth::edit::default") + return m.reply(_("you can't edit %{user}") % {:user=>butarget.username}) if + butarget != botuser && !botuser.permit?("auth::edit::other") + + return m.reply(need_args(cmd)) unless splits[1] + arg = splits[1].to_sym + return m.reply(not_args(cmd, *can_set)) unless can_set.include?(arg) + argarg = splits[2] + return m.reply(need_args([cmd, splits[1]].join(" "))) unless argarg + if arg == :password && m.public? + return m.reply(_("is that a joke? setting the password in public?")) + end + set_prop(butarget, arg, argarg) + @bot.auth.set_changed + auth_manage_user(m, {:data => ["show", arg.to_s, "for", butarget.username] }) + + when :reset + return m.reply(_("you can't change the default user")) if + butarget.default? && !botuser.permit?("auth::edit::default") + return m.reply(_("you can't edit %{user}") % {:user=>butarget.username}) if + butarget != botuser && !botuser.permit?("auth::edit::other") + + return m.reply(need_args(cmd)) unless splits[1] + things = [] + skipped = [] + splits[1..-1].each { |a| + arg = a.to_sym + if can_reset.include?(arg) + reset_prop(butarget, arg) + things << a + else + skipped << a + end + } + + m.reply(_("I ignored %{things} because %{reason}") % + { :things => skipped.join(', '), + :reason => not_args(cmd, *can_reset)}) unless skipped.empty? + if things.empty? + m.reply _("I haven't changed anything") + else + @bot.auth.set_changed + @bot.say(m.source, _("the password for %{user} is now %{password}") % + {:user => butarget.username, :password => butarget.password}) if + things.include?("password") + return auth_manage_user(m, {:data => (["show"] + things - ["password"]) + ["for", butarget.username]}) + end + + when :add, :rm, :remove, :del, :delete + return m.reply(_("you can't change the default user")) if + butarget.default? && !botuser.permit?("auth::edit::default") + return m.reply(_("you can't edit %{user}") % {:user => butarget.username}) if + butarget != botuser && !botuser.permit?("auth::edit::other") + + arg = splits[1] + if arg.nil? or arg !~ /netmasks?/ or splits[2].nil? + return m.reply(_("I can only add/remove netmasks. See +help user add+ for more instructions")) + end + + method = cmd.to_sym == :add ? :add_netmask : :delete_netmask + + failed = [] + + splits[2..-1].each { |mask| + begin + butarget.send(method, mask.to_irc_netmask(:server => @bot.server)) + rescue => e + debug "failed with #{e.message}" + debug e.backtrace.join("\n") + failed << mask + end + } + m.reply "I failed to #{cmd} #{failed.join(', ')}" unless failed.empty? + @bot.auth.set_changed + return auth_manage_user(m, {:data => ["show", "netmasks", "for", butarget.username] }) + + else + m.reply _("sorry, I don't know how to %{request}") % {:request => m.message} + end + rescue => e + m.reply _("couldn't %{cmd}: %{exception}") % {:cmd => cmd, :exception => e} + end + end + + def auth_meet(m, params) + nick = params[:nick] + if !nick + # we are actually responding to a 'hello' command + unless m.botuser.transient? + m.reply @bot.lang.get('hello_X') % m.botuser + return + end + nick = m.sourcenick + irc_user = m.source + else + # m.channel is always an Irc::Channel because the command is either + # public-only 'meet' or private/public 'hello' which was handled by + # the !nick case, so this shouldn't fail + irc_user = m.channel.users[nick] + return m.reply("I don't see anyone named '#{nick}' here") unless irc_user + end + # BotUser name + buname = params[:user] || nick + begin + call_event(:botuser,:pre_perm, {:irc_user => irc_user, :bot_user => buname}) + met = @bot.auth.make_permanent(irc_user, buname) + @bot.auth.set_changed + call_event(:botuser,:post_perm, {:irc_user => irc_user, :bot_user => buname}) + m.reply @bot.lang.get('hello_X') % met + @bot.say nick, _("you are now registered as %{buname}. I created a random password for you : %{pass} and you can change it at any time by telling me 'user set password <password>' in private" % { + :buname => buname, + :pass => met.password + }) + rescue RuntimeError + # or can this happen for other cases too? + # TODO autologin if forced + m.reply _("but I already know %{buname}" % {:buname => buname}) + rescue => e + m.reply _("I had problems meeting %{nick}: %{e}" % { :nick => nick, :e => e }) + end + end + + def auth_tell_password(m, params) + user = params[:user] + begin + botuser = @bot.auth.get_botuser(params[:botuser]) + rescue + return m.reply(_("couldn't find botuser %{user}") % {:user => params[:botuser]}) + end + m.reply(_("I'm not telling the master password to anyway, pal")) if botuser == @bot.auth.botowner + msg = _("the password for botuser %{user} is %{password}") % + {:user => botuser.username, :password => botuser.password} + @bot.say user, msg + @bot.say m.source, _("I told %{user} that %{message}") % {:user => user, :message => msg} + end + + def auth_create_user(m, params) + name = params[:name] + password = params[:password] + return m.reply(_("are you nuts, creating a botuser with a publicly known password?")) if m.public? and not password.nil? + begin + bu = @bot.auth.create_botuser(name, password) + @bot.auth.set_changed + rescue => e + m.reply(_("failed to create %{user}: %{exception}") % {:user => name, :exception => e}) + debug e.inspect + "\n" + e.backtrace.join("\n") + return + end + m.reply(_("created botuser %{user}") % {:user => bu.username}) + end + + def auth_list_users(m, params) + # TODO name regexp to filter results + list = @bot.auth.save_array.inject([]) { |list, x| ['everyone', 'owner'].include?(x[:username]) ? list : list << x[:username] } + if defined?(@destroy_q) + list.map! { |x| + @destroy_q.include?(x) ? x + _(" (queued for destruction)") : x + } + end + return m.reply(_("I have no botusers other than the default ones")) if list.empty? + return m.reply(n_("botuser: %{list}", "botusers: %{list}", list.length) % + {:list => list.join(', ')}) + end + + def auth_destroy_user(m, params) + @destroy_q = [] unless defined?(@destroy_q) + buname = params[:name] + return m.reply(_("You can't destroy %{user}") % {:user => buname}) if + ["everyone", "owner"].include?(buname) + mod = params[:modifier].to_sym rescue nil + + buser_array = @bot.auth.save_array + buser_hash = buser_array.inject({}) { |h, u| + h[u[:username]] = u + h + } + + return m.reply(_("no such botuser %{user}") % {:user=>buname}) unless + buser_hash.keys.include?(buname) + + case mod + when :cancel + if @destroy_q.include?(buname) + @destroy_q.delete(buname) + m.reply(_("%{user} removed from the destruction queue") % {:user=>buname}) + else + m.reply(_("%{user} was not queued for destruction") % {:user=>buname}) + end + return + when nil + if @destroy_q.include?(buname) + return m.reply(_("%{user} already queued for destruction, use %{highlight}user confirm destroy %{user}%{highlight} to destroy it") % {:user=>buname, :highlight=>Bold}) + else + @destroy_q << buname + return m.reply(_("%{user} queued for destruction, use %{highlight}user confirm destroy %{user}%{highlight} to destroy it") % {:user=>buname, :highlight=>Bold}) + end + when :confirm + begin + return m.reply(_("%{user} is not queued for destruction yet") % + {:user=>buname}) unless @destroy_q.include?(buname) + buser_array.delete_if { |u| + u[:username] == buname + } + @destroy_q.delete(buname) + @bot.auth.load_array(buser_array, true) + @bot.auth.set_changed + rescue => e + return m.reply(_("failed: %{exception}") % {:exception => e}) + end + return m.reply(_("botuser %{user} destroyed") % {:user => buname}) + end + end + + def auth_copy_ren_user(m, params) + source = Auth::BotUser.sanitize_username(params[:source]) + dest = Auth::BotUser.sanitize_username(params[:dest]) + return m.reply(_("please don't touch the default users")) unless + (["everyone", "owner"] & [source, dest]).empty? + + buser_array = @bot.auth.save_array + buser_hash = buser_array.inject({}) { |h, u| + h[u[:username]] = u + h + } + + return m.reply(_("no such botuser %{source}") % {:source=>source}) unless + buser_hash.keys.include?(source) + return m.reply(_("botuser %{dest} exists already") % {:dest=>dest}) if + buser_hash.keys.include?(dest) + + copying = m.message.split[1] == "copy" + begin + if copying + h = {} + buser_hash[source].each { |k, val| + h[k] = val.dup + } + else + h = buser_hash[source] + end + h[:username] = dest + buser_array << h if copying + + @bot.auth.load_array(buser_array, true) + @bot.auth.set_changed + call_event(:botuser, copying ? :copy : :rename, :source => source, :dest => dest) + rescue => e + return m.reply(_("failed: %{exception}") % {:exception=>e}) + end + if copying + m.reply(_("botuser %{source} copied to %{dest}") % + {:source=>source, :dest=>dest}) + else + m.reply(_("botuser %{source} renamed to %{dest}") % + {:source=>source, :dest=>dest}) + end + + end + + def auth_export(m, params) + + exportfile = "#{@bot.botclass}/new-auth.users" + + what = params[:things] + + has_to = what[-2] == "to" + if has_to + exportfile = "#{@bot.botclass}/#{what[-1]}" + what.slice!(-2,2) + end + + what.delete("all") + + m.reply _("selecting data to export ...") + + buser_array = @bot.auth.save_array + buser_hash = buser_array.inject({}) { |h, u| + h[u[:username]] = u + h + } + + if what.empty? + we_want = buser_hash + else + we_want = buser_hash.delete_if { |key, val| + not what.include?(key) + } + end + + m.reply _("preparing data for export ...") + begin + yaml_hash = {} + we_want.each { |k, val| + yaml_hash[k] = {} + val.each { |kk, v| + case kk + when :username + next + when :netmasks + yaml_hash[k][kk] = [] + v.each { |nm| + yaml_hash[k][kk] << { + :fullform => nm.fullform, + :casemap => nm.casemap.to_s + } + } + else + yaml_hash[k][kk] = v + end + } + } + rescue => e + m.reply _("failed to prepare data: %{exception}") % {:exception=>e} + debug e.backtrace.dup.unshift(e.inspect).join("\n") + return + end + + m.reply _("exporting to %{file} ...") % {:file=>exportfile} + begin + # m.reply yaml_hash.inspect + File.open(exportfile, "w") do |file| + file.puts YAML::dump(yaml_hash) + end + rescue => e + m.reply _("failed to export users: %{exception}") % {:exception=>e} + debug e.backtrace.dup.unshift(e.inspect).join("\n") + return + end + m.reply _("done") + end + + def auth_import(m, params) + + importfile = "#{@bot.botclass}/new-auth.users" + + what = params[:things] + + has_from = what[-2] == "from" + if has_from + importfile = "#{@bot.botclass}/#{what[-1]}" + what.slice!(-2,2) + end + + what.delete("all") + + m.reply _("reading %{file} ...") % {:file=>importfile} + begin + yaml_hash = YAML::load_file(importfile) + rescue => e + m.reply _("failed to import from: %{exception}") % {:exception=>e} + debug e.backtrace.dup.unshift(e.inspect).join("\n") + return + end + + # m.reply yaml_hash.inspect + + m.reply _("selecting data to import ...") + + if what.empty? + we_want = yaml_hash + else + we_want = yaml_hash.delete_if { |key, val| + not what.include?(key) + } + end + + m.reply _("parsing data from import ...") + + buser_hash = {} + + begin + yaml_hash.each { |k, val| + buser_hash[k] = { :username => k } + val.each { |kk, v| + case kk + when :netmasks + buser_hash[k][kk] = [] + v.each { |nm| + buser_hash[k][kk] << nm[:fullform].to_irc_netmask(:casemap => nm[:casemap].to_irc_casemap).to_irc_netmask(:server => @bot.server) + } + else + buser_hash[k][kk] = v + end + } + } + rescue => e + m.reply _("failed to parse data: %{exception}") % {:exception=>e} + debug e.backtrace.dup.unshift(e.inspect).join("\n") + return + end + + # m.reply buser_hash.inspect + + org_buser_array = @bot.auth.save_array + org_buser_hash = org_buser_array.inject({}) { |h, u| + h[u[:username]] = u + h + } + + # TODO we may want to do a(n optional) key-by-key merge + # + org_buser_hash.merge!(buser_hash) + new_buser_array = org_buser_hash.values + @bot.auth.load_array(new_buser_array, true) + @bot.auth.set_changed + + m.reply _("done") + end + +end + +auth = AuthModule.new + +auth.map "user export *things", + :action => 'auth_export', + :defaults => { :things => ['all'] }, + :auth_path => ':manage:fedex:' + +auth.map "user import *things", + :action => 'auth_import', + :auth_path => ':manage:fedex:' + +auth.map "user create :name :password", + :action => 'auth_create_user', + :defaults => {:password => nil}, + :auth_path => ':manage:' + +auth.map "user [:modifier] destroy :name", + :action => 'auth_destroy_user', + :requirements => { :modifier => /^(cancel|confirm)?$/ }, + :defaults => { :modifier => '' }, + :auth_path => ':manage::destroy!' + +auth.map "user copy :source [to] :dest", + :action => 'auth_copy_ren_user', + :auth_path => ':manage:' + +auth.map "user rename :source [to] :dest", + :action => 'auth_copy_ren_user', + :auth_path => ':manage:' + +auth.map "meet :nick [as :user]", + :action => 'auth_meet', + :auth_path => 'user::manage', :private => false + +auth.map "hello", + :action => 'auth_meet', + :auth_path => 'user::manage::meet' + +auth.default_auth("user::manage", false) +auth.default_auth("user::manage::meet::hello", true) + +auth.map "user tell :user the password for :botuser", + :action => 'auth_tell_password', + :auth_path => ':manage:' + +auth.map "user list", + :action => 'auth_list_users', + :auth_path => '::' + +auth.map "user *data", + :action => 'auth_manage_user' + +auth.default_auth("user", true) +auth.default_auth("edit::other", false) + +auth.map "whoami", + :action => 'auth_whoami', + :auth_path => '!*!' + +auth.map "who is :user", + :action => 'auth_whois', + :auth_path => '!*!' + +auth.map "auth :password", + :action => 'auth_auth', + :public => false, + :auth_path => '!login!' + +auth.map "login :botuser :password", + :action => 'auth_login', + :public => false, + :defaults => { :password => nil }, + :auth_path => '!login!' + +auth.map "login :botuser", + :action => 'auth_login', + :auth_path => '!login!' + +auth.map "login", + :action => 'auth_autologin', + :auth_path => '!login!' + +auth.map "permissions set *args", + :action => 'auth_edit_perm', + :auth_path => ':edit::set:' + +auth.map "permissions reset *args", + :action => 'auth_edit_perm', + :auth_path => ':edit::set:' + +auth.map "permissions view [for :user]", + :action => 'auth_view_perm', + :auth_path => '::' + +auth.map "permissions search *pattern", + :action => 'auth_search_perm', + :auth_path => '::' + +auth.default_auth('*', false) + diff --git a/lib/rbot/core/basics.rb b/lib/rbot/core/basics.rb index 4b5ab7d3..7a5d82c1 100644 --- a/lib/rbot/core/basics.rb +++ b/lib/rbot/core/basics.rb @@ -1,188 +1,188 @@ -#-- vim:sw=2:et
-#++
-#
-# :title: rbot basic management from IRC
-#
-# Author:: Giuseppe "Oblomov" Bilotta <giuseppe.bilotta@gmail.com>
-# Copyright:: (C) 2006,2007 Giuseppe Bilotta
-# License:: GPL v2
-
-class BasicsModule < CoreBotModule
-
- def ctcp_listen(m)
- who = m.private? ? "me" : m.target
- case m.ctcp.intern
- when :PING
- m.ctcp_reply m.message
- @bot.irclog "@ #{m.source} pinged #{who}"
- when :TIME
- m.ctcp_reply Time.now.to_s
- @bot.irclog "@ #{m.source} asked #{who} what time it is"
- end
- end
-
- def bot_join(m, param)
- if param[:pass]
- @bot.join param[:chan], param[:pass]
- else
- @bot.join param[:chan]
- end
- end
-
- def bot_part(m, param)
- if param[:chan]
- @bot.part param[:chan]
- else
- @bot.part m.target if m.public?
- end
- end
-
- def bot_quit(m, param)
- @bot.quit param[:msg].to_s
- end
-
- def bot_restart(m, param)
- @bot.restart param[:msg].to_s
- end
-
- def bot_hide(m, param)
- @bot.join 0
- end
-
- def bot_say(m, param)
- @bot.say param[:where], param[:what].to_s
- end
-
- def bot_action(m, param)
- @bot.action param[:where], param[:what].to_s
- end
-
- def bot_mode(m, param)
- @bot.mode param[:where], param[:what], param[:who].join(" ")
- end
-
- def bot_ping(m, param)
- m.reply "pong"
- end
-
- def bot_quiet(m, param)
- if param.has_key?(:where)
- @bot.set_quiet param[:where].sub(/^here$/, m.target.downcase)
- else
- @bot.set_quiet
- end
- # Make sense when the commmand is given in private or in a non-quieted
- # channel
- m.okay
- end
-
- def bot_talk(m, param)
- if param.has_key?(:where)
- @bot.reset_quiet param[:where].sub(/^here$/, m.target.downcase)
- else
- @bot.reset_quiet
- end
- # Make sense when the commmand is given in private or in a non-quieted
- # channel
- m.okay
- end
-
- def bot_help(m, param)
- m.reply @bot.help(param[:topic].join(" "))
- end
-
- #TODO move these to a "chatback" plugin
- # when (/^(botsnack|ciggie)$/i)
- # @bot.say m.replyto, @lang.get("thanks_X") % m.sourcenick if(m.public?)
- # @bot.say m.replyto, @lang.get("thanks") if(m.private?)
- # when (/^#{Regexp.escape(@bot.nick)}!*$/)
- # @bot.say m.replyto, @lang.get("hello_X") % m.sourcenick
-
- # handle help requests for "core" topics
- def help(cmd, topic="")
- case cmd
- when "quit"
- _("quit [<message>] => quit IRC with message <message>")
- when "restart"
- _("restart => completely stop and restart the bot (including reconnect)")
- when "join"
- _("join <channel> [<key>] => join channel <channel> with secret key <key> if specified. #{@bot.myself} also responds to invites if you have the required access level")
- when "part"
- _("part <channel> => part channel <channel>")
- when "hide"
- _("hide => part all channels")
- when "nick"
- _("nick <nick> => attempt to change nick to <nick>")
- when "say"
- _("say <channel>|<nick> <message> => say <message> to <channel> or in private message to <nick>")
- when "action"
- _("action <channel>|<nick> <message> => does a /me <message> to <channel> or in private message to <nick>")
- when "quiet"
- _("quiet [in here|<channel>] => with no arguments, stop speaking in all channels, if \"in here\", stop speaking in this channel, or stop speaking in <channel>")
- when "talk"
- _("talk [in here|<channel>] => with no arguments, resume speaking in all channels, if \"in here\", resume speaking in this channel, or resume speaking in <channel>")
- when "ping"
- _("ping => replies with a pong")
- when "mode"
- _("mode <channel> <mode> <nicks> => set channel modes for <nicks> on <channel> to <mode>")
- # when "botsnack"
- # return "botsnack => reward #{@bot.myself} for being good"
- # when "hello"
- # return "hello|hi|hey|yo [#{@bot.myself}] => greet the bot"
- else
- _("%{name}: quit, restart, join, part, hide, save, nick, say, action, topic, quiet, talk, ping, mode") % {:name=>name}
- #, botsnack, hello
- end
- end
-end
-
-basics = BasicsModule.new
-
-basics.map "quit *msg",
- :action => 'bot_quit',
- :defaults => { :msg => nil },
- :auth_path => 'quit'
-basics.map "restart *msg",
- :action => 'bot_restart',
- :defaults => { :msg => nil },
- :auth_path => 'quit'
-
-basics.map "quiet [in] [:where]",
- :action => 'bot_quiet',
- :auth_path => 'talk::set'
-basics.map "talk [in] [:where]",
- :action => 'bot_talk',
- :auth_path => 'talk::set'
-
-basics.map "say :where *what",
- :action => 'bot_say',
- :auth_path => 'talk::do'
-basics.map "action :where *what",
- :action => 'bot_action',
- :auth_path => 'talk::do'
-basics.map "mode :where :what *who",
- :action => 'bot_mode',
- :auth_path => 'talk::do'
-
-basics.map "join :chan :pass",
- :action => 'bot_join',
- :defaults => {:pass => nil},
- :auth_path => 'move'
-basics.map "part :chan",
- :action => 'bot_part',
- :defaults => {:chan => nil},
- :auth_path => 'move'
-basics.map "hide",
- :action => 'bot_hide',
- :auth_path => 'move'
-
-basics.map "ping",
- :action => 'bot_ping',
- :auth_path => '!ping!'
-basics.map "help *topic",
- :action => 'bot_help',
- :defaults => { :topic => [""] },
- :auth_path => '!help!'
-
-basics.default_auth('*', false)
-
+#-- vim:sw=2:et +#++ +# +# :title: rbot basic management from IRC +# +# Author:: Giuseppe "Oblomov" Bilotta <giuseppe.bilotta@gmail.com> +# Copyright:: (C) 2006,2007 Giuseppe Bilotta +# License:: GPL v2 + +class BasicsModule < CoreBotModule + + def ctcp_listen(m) + who = m.private? ? "me" : m.target + case m.ctcp.intern + when :PING + m.ctcp_reply m.message + @bot.irclog "@ #{m.source} pinged #{who}" + when :TIME + m.ctcp_reply Time.now.to_s + @bot.irclog "@ #{m.source} asked #{who} what time it is" + end + end + + def bot_join(m, param) + if param[:pass] + @bot.join param[:chan], param[:pass] + else + @bot.join param[:chan] + end + end + + def bot_part(m, param) + if param[:chan] + @bot.part param[:chan] + else + @bot.part m.target if m.public? + end + end + + def bot_quit(m, param) + @bot.quit param[:msg].to_s + end + + def bot_restart(m, param) + @bot.restart param[:msg].to_s + end + + def bot_hide(m, param) + @bot.join 0 + end + + def bot_say(m, param) + @bot.say param[:where], param[:what].to_s + end + + def bot_action(m, param) + @bot.action param[:where], param[:what].to_s + end + + def bot_mode(m, param) + @bot.mode param[:where], param[:what], param[:who].join(" ") + end + + def bot_ping(m, param) + m.reply "pong" + end + + def bot_quiet(m, param) + if param.has_key?(:where) + @bot.set_quiet param[:where].sub(/^here$/, m.target.downcase) + else + @bot.set_quiet + end + # Make sense when the commmand is given in private or in a non-quieted + # channel + m.okay + end + + def bot_talk(m, param) + if param.has_key?(:where) + @bot.reset_quiet param[:where].sub(/^here$/, m.target.downcase) + else + @bot.reset_quiet + end + # Make sense when the commmand is given in private or in a non-quieted + # channel + m.okay + end + + def bot_help(m, param) + m.reply @bot.help(param[:topic].join(" ")) + end + + #TODO move these to a "chatback" plugin + # when (/^(botsnack|ciggie)$/i) + # @bot.say m.replyto, @lang.get("thanks_X") % m.sourcenick if(m.public?) + # @bot.say m.replyto, @lang.get("thanks") if(m.private?) + # when (/^#{Regexp.escape(@bot.nick)}!*$/) + # @bot.say m.replyto, @lang.get("hello_X") % m.sourcenick + + # handle help requests for "core" topics + def help(cmd, topic="") + case cmd + when "quit" + _("quit [<message>] => quit IRC with message <message>") + when "restart" + _("restart => completely stop and restart the bot (including reconnect)") + when "join" + _("join <channel> [<key>] => join channel <channel> with secret key <key> if specified. #{@bot.myself} also responds to invites if you have the required access level") + when "part" + _("part <channel> => part channel <channel>") + when "hide" + _("hide => part all channels") + when "nick" + _("nick <nick> => attempt to change nick to <nick>") + when "say" + _("say <channel>|<nick> <message> => say <message> to <channel> or in private message to <nick>") + when "action" + _("action <channel>|<nick> <message> => does a /me <message> to <channel> or in private message to <nick>") + when "quiet" + _("quiet [in here|<channel>] => with no arguments, stop speaking in all channels, if \"in here\", stop speaking in this channel, or stop speaking in <channel>") + when "talk" + _("talk [in here|<channel>] => with no arguments, resume speaking in all channels, if \"in here\", resume speaking in this channel, or resume speaking in <channel>") + when "ping" + _("ping => replies with a pong") + when "mode" + _("mode <channel> <mode> <nicks> => set channel modes for <nicks> on <channel> to <mode>") + # when "botsnack" + # return "botsnack => reward #{@bot.myself} for being good" + # when "hello" + # return "hello|hi|hey|yo [#{@bot.myself}] => greet the bot" + else + _("%{name}: quit, restart, join, part, hide, save, nick, say, action, topic, quiet, talk, ping, mode") % {:name=>name} + #, botsnack, hello + end + end +end + +basics = BasicsModule.new + +basics.map "quit *msg", + :action => 'bot_quit', + :defaults => { :msg => nil }, + :auth_path => 'quit' +basics.map "restart *msg", + :action => 'bot_restart', + :defaults => { :msg => nil }, + :auth_path => 'quit' + +basics.map "quiet [in] [:where]", + :action => 'bot_quiet', + :auth_path => 'talk::set' +basics.map "talk [in] [:where]", + :action => 'bot_talk', + :auth_path => 'talk::set' + +basics.map "say :where *what", + :action => 'bot_say', + :auth_path => 'talk::do' +basics.map "action :where *what", + :action => 'bot_action', + :auth_path => 'talk::do' +basics.map "mode :where :what *who", + :action => 'bot_mode', + :auth_path => 'talk::do' + +basics.map "join :chan :pass", + :action => 'bot_join', + :defaults => {:pass => nil}, + :auth_path => 'move' +basics.map "part :chan", + :action => 'bot_part', + :defaults => {:chan => nil}, + :auth_path => 'move' +basics.map "hide", + :action => 'bot_hide', + :auth_path => 'move' + +basics.map "ping", + :action => 'bot_ping', + :auth_path => '!ping!' +basics.map "help *topic", + :action => 'bot_help', + :defaults => { :topic => [""] }, + :auth_path => '!help!' + +basics.default_auth('*', false) + diff --git a/lib/rbot/core/config.rb b/lib/rbot/core/config.rb index ad9b7c74..1b14ebd8 100644 --- a/lib/rbot/core/config.rb +++ b/lib/rbot/core/config.rb @@ -1,325 +1,325 @@ -#-- vim:sw=2:et
-#++
-#
-# :title: rbot config management from IRC
-#
-# Author:: Giuseppe "Oblomov" Bilotta <giuseppe.bilotta@gmail.com>
-# Copyright:: (C) 2006,2007 Giuseppe Bilotta
-# License:: GPL v2
-
-class ConfigModule < CoreBotModule
-
- def version_string
- _("I'm a v. %{version} rubybot%{copyright}%{url}") % {
- :version => $version,
- :copyright => ", #{Irc::Bot::COPYRIGHT_NOTICE}",
- :url => " - #{Irc::Bot::SOURCE_URL}"
- }
- end
-
- def save
- @bot.config.save
- end
-
- def handle_list(m, params)
- modules = []
- if params[:module]
- @bot.config.items.each_key do |key|
- mod, name = key.to_s.split('.')
- next unless mod == params[:module]
- modules.push key unless modules.include?(name)
- end
- if modules.empty?
- m.reply _("no such module %{module}") % {:module => params[:module]}
- else
- m.reply modules.join(", ")
- end
- else
- @bot.config.items.each_key do |key|
- name = key.to_s.split('.').first
- modules.push name unless modules.include?(name)
- end
- m.reply "modules: " + modules.join(", ")
- end
- end
-
- def handle_get(m, params)
- key = params[:key].to_s.intern
- unless @bot.config.items.has_key?(key)
- m.reply _("no such config key %{key}") % {:key => key}
- return
- end
- return if !@bot.auth.allow?(@bot.config.items[key].auth_path, m.source, m.replyto)
- value = @bot.config.items[key].to_s
- m.reply "#{key}: #{value}"
- end
-
- def handle_desc(m, params)
- key = params[:key].to_s.intern
- unless @bot.config.items.has_key?(key)
- m.reply _("no such config key %{key}") % {:key => key}
- end
- m.reply "#{key}: #{@bot.config.items[key].desc}"
- end
-
- def handle_search(m, params)
- rx = Regexp.new(params[:rx].to_s, true)
- cfs = []
- @bot.config.items.each do |k, v|
- cfs << v if k.to_s.match(rx) or (v.desc.match(rx) rescue false)
- end
- if cfs.empty?
- m.reply _("no config key found matching %{r}") % { :r => params[:rx].to_s}
- else
- m.reply _("possible keys: %{kl}") % { :kl => cfs.map { |c| c.key}.join(', ') }
- m.reply cfs.map { |c| [c.key, c.desc].join(': ') }.join("\n")
- end
- end
-
- def handle_unset(m, params)
- key = params[:key].to_s.intern
- unless @bot.config.items.has_key?(key)
- m.reply _("no such config key %{key}") % {:key => key}
- end
- return if !@bot.auth.allow?(@bot.config.items[key].auth_path, m.source, m.replyto)
- @bot.config.items[key].unset
- handle_get(m, params)
- m.reply _("this config change will take effect on the next restart") if @bot.config.items[key].requires_restart
- m.reply _("this config change will take effect on the next rescan") if @bot.config.items[key].requires_rescan
- end
-
- def handle_set(m, params)
- key = params[:key].to_s.intern
- value = params[:value].join(" ")
- unless @bot.config.items.has_key?(key)
- m.reply _("no such config key %{key}") % {:key => key} unless params[:silent]
- return false
- end
- return false if !@bot.auth.allow?(@bot.config.items[key].auth_path, m.source, m.replyto)
- begin
- @bot.config.items[key].set_string(value)
- rescue ArgumentError => e
- m.reply _("failed to set %{key}: %{error}") % {:key => key, :error => e.message} unless params[:silent]
- return false
- end
- if @bot.config.items[key].requires_restart
- m.reply _("this config change will take effect on the next restart") unless params[:silent]
- return :restart
- elsif @bot.config.items[key].requires_rescan
- m.reply _("this config change will take effect on the next rescan") unless params[:silent]
- return :rescan
- else
- m.okay unless params[:silent]
- return true
- end
- end
-
- def handle_add(m, params)
- key = params[:key].to_s.intern
- value = params[:value]
- unless @bot.config.items.has_key?(key)
- m.reply _("no such config key %{key}") % {:key => key}
- return
- end
- unless @bot.config.items[key].kind_of?(Config::ArrayValue)
- m.reply _("config key %{key} is not an array") % {:key => key}
- return
- end
- return if !@bot.auth.allow?(@bot.config.items[key].auth_path, m.source, m.replyto)
- begin
- @bot.config.items[key].add(value)
- rescue ArgumentError => e
- m.reply _("failed to add %{value} to %{key}: %{error}") % {:value => value, :key => key, :error => e.message}
- return
- end
- handle_get(m,{:key => key})
- m.reply _("this config change will take effect on the next restart") if @bot.config.items[key].requires_restart
- m.reply _("this config change will take effect on the next rescan") if @bot.config.items[key].requires_rescan
- end
-
- def handle_rm(m, params)
- key = params[:key].to_s.intern
- value = params[:value]
- unless @bot.config.items.has_key?(key)
- m.reply _("no such config key %{key}") % {:key => key}
- return
- end
- unless @bot.config.items[key].kind_of?(Config::ArrayValue)
- m.reply _("config key %{key} is not an array") % {:key => key}
- return
- end
- return if !@bot.auth.allow?(@bot.config.items[key].auth_path, m.source, m.replyto)
- begin
- @bot.config.items[key].rm(value)
- rescue ArgumentError => e
- m.reply _("failed to remove %{value} from %{key}: %{error}") % {:value => value, :key => key, :error => e.message}
- return
- end
- handle_get(m,{:key => key})
- m.reply _("this config change will take effect on the next restart") if @bot.config.items[key].requires_restart
- m.reply _("this config change will take effect on the next rescan") if @bot.config.items[key].requires_rescan
- end
-
- def bot_save(m, param)
- @bot.save
- m.okay
- end
-
- def bot_rescan(m, param)
- m.reply _("saving ...")
- @bot.save
- m.reply _("rescanning ...")
- @bot.rescan
- m.reply _("done. %{plugin_status}") % {:plugin_status => @bot.plugins.status(true)}
- end
-
- def bot_nick(m, param)
- @bot.nickchg(param[:nick])
- end
-
- def bot_status(m, param)
- m.reply @bot.status
- end
-
- # TODO is this one of the methods that disappeared when the bot was moved
- # from the single-file to the multi-file registry?
- #
- # def bot_reg_stat(m, param)
- # m.reply @registry.stat.inspect
- # end
-
- def bot_version(m, param)
- m.reply version_string
- end
-
- def ctcp_listen(m)
- who = m.private? ? "me" : m.target
- case m.ctcp.intern
- when :VERSION
- m.ctcp_reply version_string
- @bot.irclog "@ #{m.source} asked #{who} about version info"
- when :SOURCE
- m.ctcp_reply Irc::Bot::SOURCE_URL
- @bot.irclog "@ #{m.source} asked #{who} about source info"
- end
- end
-
- def handle_help(m, params)
- m.reply help(params[:topic])
- end
-
- def help(plugin, topic="")
- case plugin
- when "config"
- case topic
- when "list"
- _("config list => list configuration modules, config list <module> => list configuration keys for module <module>")
- when "get"
- _("config get <key> => get configuration value for key <key>")
- when "unset"
- _("reset key <key> to the default")
- when "set"
- _("config set <key> <value> => set configuration value for key <key> to <value>")
- when "desc"
- _("config desc <key> => describe what key <key> configures")
- when "add"
- _("config add <value> to <key> => add value <value> to key <key> if <key> is an array")
- when "rm"
- _("config rm <value> from <key> => remove value <value> from key <key> if <key> is an array")
- else
- _("config module - bot configuration. usage: list, desc, get, set, unset, add, rm")
- # else
- # "no help for config #{topic}"
- end
- when "nick"
- _("nick <newnick> => change the bot nick to <newnick>, if possible")
- when "status"
- _("status => display some information on the bot's status")
- when "save"
- _("save => save current dynamic data and configuration")
- when "rescan"
- _("rescan => reload modules and static facts")
- when "version"
- _("version => describes software version")
- else
- _("config-related tasks: config, save, rescan, version, nick, status")
- end
- end
-
-end
-
-conf = ConfigModule.new
-
-conf.map 'config list :module',
- :action => 'handle_list',
- :defaults => {:module => false},
- :auth_path => 'show'
-# TODO this one is presently a security risk, since the bot
-# stores the master password in the config. Do we need auth levels
-# on the Bot::Config keys too?
-conf.map 'config get :key',
- :action => 'handle_get',
- :auth_path => 'show'
-conf.map 'config desc :key',
- :action => 'handle_desc',
- :auth_path => 'show'
-conf.map 'config describe :key',
- :action => 'handle_desc',
- :auth_path => 'show::desc!'
-conf.map 'config search *rx',
- :action => 'handle_search',
- :auth_path => 'show'
-
-conf.map "save",
- :action => 'bot_save'
-conf.map "rescan",
- :action => 'bot_rescan'
-conf.map "nick :nick",
- :action => 'bot_nick'
-conf.map "status",
- :action => 'bot_status',
- :auth_path => 'show::status'
-# TODO see above
-#
-# conf.map "registry stats",
-# :action => 'bot_reg_stat',
-# :auth_path => '!config::status'
-conf.map "version",
- :action => 'bot_version',
- :auth_path => 'show::status'
-
-conf.map 'config set :key *value',
- :action => 'handle_set',
- :auth_path => 'edit'
-conf.map 'config add :value to :key',
- :action => 'handle_add',
- :auth_path => 'edit'
-conf.map 'config rm :value from :key',
- :action => 'handle_rm',
- :auth_path => 'edit'
-conf.map 'config del :value from :key',
- :action => 'handle_rm',
- :auth_path => 'edit'
-conf.map 'config delete :value from :key',
- :action => 'handle_rm',
- :auth_path => 'edit'
-conf.map 'config unset :key',
- :action => 'handle_unset',
- :auth_path => 'edit'
-conf.map 'config reset :key',
- :action => 'handle_unset',
- :auth_path => 'edit'
-
-conf.map 'config help :topic',
- :action => 'handle_help',
- :defaults => {:topic => false},
- :auth_path => '!help!'
-
-conf.default_auth('*', false)
-conf.default_auth('show', true)
-conf.default_auth('show::get', false)
-# TODO these shouldn't be set here, we need a way to let the default
-# permission be specified together with the ConfigValue
-conf.default_auth('key', true)
-conf.default_auth('key::auth::password', false)
-
+#-- vim:sw=2:et +#++ +# +# :title: rbot config management from IRC +# +# Author:: Giuseppe "Oblomov" Bilotta <giuseppe.bilotta@gmail.com> +# Copyright:: (C) 2006,2007 Giuseppe Bilotta +# License:: GPL v2 + +class ConfigModule < CoreBotModule + + def version_string + _("I'm a v. %{version} rubybot%{copyright}%{url}") % { + :version => $version, + :copyright => ", #{Irc::Bot::COPYRIGHT_NOTICE}", + :url => " - #{Irc::Bot::SOURCE_URL}" + } + end + + def save + @bot.config.save + end + + def handle_list(m, params) + modules = [] + if params[:module] + @bot.config.items.each_key do |key| + mod, name = key.to_s.split('.') + next unless mod == params[:module] + modules.push key unless modules.include?(name) + end + if modules.empty? + m.reply _("no such module %{module}") % {:module => params[:module]} + else + m.reply modules.join(", ") + end + else + @bot.config.items.each_key do |key| + name = key.to_s.split('.').first + modules.push name unless modules.include?(name) + end + m.reply "modules: " + modules.join(", ") + end + end + + def handle_get(m, params) + key = params[:key].to_s.intern + unless @bot.config.items.has_key?(key) + m.reply _("no such config key %{key}") % {:key => key} + return + end + return if !@bot.auth.allow?(@bot.config.items[key].auth_path, m.source, m.replyto) + value = @bot.config.items[key].to_s + m.reply "#{key}: #{value}" + end + + def handle_desc(m, params) + key = params[:key].to_s.intern + unless @bot.config.items.has_key?(key) + m.reply _("no such config key %{key}") % {:key => key} + end + m.reply "#{key}: #{@bot.config.items[key].desc}" + end + + def handle_search(m, params) + rx = Regexp.new(params[:rx].to_s, true) + cfs = [] + @bot.config.items.each do |k, v| + cfs << v if k.to_s.match(rx) or (v.desc.match(rx) rescue false) + end + if cfs.empty? + m.reply _("no config key found matching %{r}") % { :r => params[:rx].to_s} + else + m.reply _("possible keys: %{kl}") % { :kl => cfs.map { |c| c.key}.join(', ') } + m.reply cfs.map { |c| [c.key, c.desc].join(': ') }.join("\n") + end + end + + def handle_unset(m, params) + key = params[:key].to_s.intern + unless @bot.config.items.has_key?(key) + m.reply _("no such config key %{key}") % {:key => key} + end + return if !@bot.auth.allow?(@bot.config.items[key].auth_path, m.source, m.replyto) + @bot.config.items[key].unset + handle_get(m, params) + m.reply _("this config change will take effect on the next restart") if @bot.config.items[key].requires_restart + m.reply _("this config change will take effect on the next rescan") if @bot.config.items[key].requires_rescan + end + + def handle_set(m, params) + key = params[:key].to_s.intern + value = params[:value].join(" ") + unless @bot.config.items.has_key?(key) + m.reply _("no such config key %{key}") % {:key => key} unless params[:silent] + return false + end + return false if !@bot.auth.allow?(@bot.config.items[key].auth_path, m.source, m.replyto) + begin + @bot.config.items[key].set_string(value) + rescue ArgumentError => e + m.reply _("failed to set %{key}: %{error}") % {:key => key, :error => e.message} unless params[:silent] + return false + end + if @bot.config.items[key].requires_restart + m.reply _("this config change will take effect on the next restart") unless params[:silent] + return :restart + elsif @bot.config.items[key].requires_rescan + m.reply _("this config change will take effect on the next rescan") unless params[:silent] + return :rescan + else + m.okay unless params[:silent] + return true + end + end + + def handle_add(m, params) + key = params[:key].to_s.intern + value = params[:value] + unless @bot.config.items.has_key?(key) + m.reply _("no such config key %{key}") % {:key => key} + return + end + unless @bot.config.items[key].kind_of?(Config::ArrayValue) + m.reply _("config key %{key} is not an array") % {:key => key} + return + end + return if !@bot.auth.allow?(@bot.config.items[key].auth_path, m.source, m.replyto) + begin + @bot.config.items[key].add(value) + rescue ArgumentError => e + m.reply _("failed to add %{value} to %{key}: %{error}") % {:value => value, :key => key, :error => e.message} + return + end + handle_get(m,{:key => key}) + m.reply _("this config change will take effect on the next restart") if @bot.config.items[key].requires_restart + m.reply _("this config change will take effect on the next rescan") if @bot.config.items[key].requires_rescan + end + + def handle_rm(m, params) + key = params[:key].to_s.intern + value = params[:value] + unless @bot.config.items.has_key?(key) + m.reply _("no such config key %{key}") % {:key => key} + return + end + unless @bot.config.items[key].kind_of?(Config::ArrayValue) + m.reply _("config key %{key} is not an array") % {:key => key} + return + end + return if !@bot.auth.allow?(@bot.config.items[key].auth_path, m.source, m.replyto) + begin + @bot.config.items[key].rm(value) + rescue ArgumentError => e + m.reply _("failed to remove %{value} from %{key}: %{error}") % {:value => value, :key => key, :error => e.message} + return + end + handle_get(m,{:key => key}) + m.reply _("this config change will take effect on the next restart") if @bot.config.items[key].requires_restart + m.reply _("this config change will take effect on the next rescan") if @bot.config.items[key].requires_rescan + end + + def bot_save(m, param) + @bot.save + m.okay + end + + def bot_rescan(m, param) + m.reply _("saving ...") + @bot.save + m.reply _("rescanning ...") + @bot.rescan + m.reply _("done. %{plugin_status}") % {:plugin_status => @bot.plugins.status(true)} + end + + def bot_nick(m, param) + @bot.nickchg(param[:nick]) + end + + def bot_status(m, param) + m.reply @bot.status + end + + # TODO is this one of the methods that disappeared when the bot was moved + # from the single-file to the multi-file registry? + # + # def bot_reg_stat(m, param) + # m.reply @registry.stat.inspect + # end + + def bot_version(m, param) + m.reply version_string + end + + def ctcp_listen(m) + who = m.private? ? "me" : m.target + case m.ctcp.intern + when :VERSION + m.ctcp_reply version_string + @bot.irclog "@ #{m.source} asked #{who} about version info" + when :SOURCE + m.ctcp_reply Irc::Bot::SOURCE_URL + @bot.irclog "@ #{m.source} asked #{who} about source info" + end + end + + def handle_help(m, params) + m.reply help(params[:topic]) + end + + def help(plugin, topic="") + case plugin + when "config" + case topic + when "list" + _("config list => list configuration modules, config list <module> => list configuration keys for module <module>") + when "get" + _("config get <key> => get configuration value for key <key>") + when "unset" + _("reset key <key> to the default") + when "set" + _("config set <key> <value> => set configuration value for key <key> to <value>") + when "desc" + _("config desc <key> => describe what key <key> configures") + when "add" + _("config add <value> to <key> => add value <value> to key <key> if <key> is an array") + when "rm" + _("config rm <value> from <key> => remove value <value> from key <key> if <key> is an array") + else + _("config module - bot configuration. usage: list, desc, get, set, unset, add, rm") + # else + # "no help for config #{topic}" + end + when "nick" + _("nick <newnick> => change the bot nick to <newnick>, if possible") + when "status" + _("status => display some information on the bot's status") + when "save" + _("save => save current dynamic data and configuration") + when "rescan" + _("rescan => reload modules and static facts") + when "version" + _("version => describes software version") + else + _("config-related tasks: config, save, rescan, version, nick, status") + end + end + +end + +conf = ConfigModule.new + +conf.map 'config list :module', + :action => 'handle_list', + :defaults => {:module => false}, + :auth_path => 'show' +# TODO this one is presently a security risk, since the bot +# stores the master password in the config. Do we need auth levels +# on the Bot::Config keys too? +conf.map 'config get :key', + :action => 'handle_get', + :auth_path => 'show' +conf.map 'config desc :key', + :action => 'handle_desc', + :auth_path => 'show' +conf.map 'config describe :key', + :action => 'handle_desc', + :auth_path => 'show::desc!' +conf.map 'config search *rx', + :action => 'handle_search', + :auth_path => 'show' + +conf.map "save", + :action => 'bot_save' +conf.map "rescan", + :action => 'bot_rescan' +conf.map "nick :nick", + :action => 'bot_nick' +conf.map "status", + :action => 'bot_status', + :auth_path => 'show::status' +# TODO see above +# +# conf.map "registry stats", +# :action => 'bot_reg_stat', +# :auth_path => '!config::status' +conf.map "version", + :action => 'bot_version', + :auth_path => 'show::status' + +conf.map 'config set :key *value', + :action => 'handle_set', + :auth_path => 'edit' +conf.map 'config add :value to :key', + :action => 'handle_add', + :auth_path => 'edit' +conf.map 'config rm :value from :key', + :action => 'handle_rm', + :auth_path => 'edit' +conf.map 'config del :value from :key', + :action => 'handle_rm', + :auth_path => 'edit' +conf.map 'config delete :value from :key', + :action => 'handle_rm', + :auth_path => 'edit' +conf.map 'config unset :key', + :action => 'handle_unset', + :auth_path => 'edit' +conf.map 'config reset :key', + :action => 'handle_unset', + :auth_path => 'edit' + +conf.map 'config help :topic', + :action => 'handle_help', + :defaults => {:topic => false}, + :auth_path => '!help!' + +conf.default_auth('*', false) +conf.default_auth('show', true) +conf.default_auth('show::get', false) +# TODO these shouldn't be set here, we need a way to let the default +# permission be specified together with the ConfigValue +conf.default_auth('key', true) +conf.default_auth('key::auth::password', false) + diff --git a/lib/rbot/irc.rb b/lib/rbot/irc.rb index 8ef848f7..fe1aa9fa 100644 --- a/lib/rbot/irc.rb +++ b/lib/rbot/irc.rb @@ -1,1958 +1,1958 @@ -#-- 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
-
- # 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 <tt>{}|^</tt> as the uppercase
- # equivalents of # <tt>[]\~</tt>.
- #
- # This is however not the same on all IRC servers: some use standard ASCII
- # casemapping, other do not consider <tt>^</tt> as the uppercase of
- # <tt>~</tt>
- #
- 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
- warn "Casemap mismatch (#{self.inspect} != #{other.inspect})"
- return false
- end
- 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 <tt>*</tt> stands for "any
- # number of arbitrary characters", a <tt>?</tt> stands for "one and exactly
- # one arbitrary character". These characters can be escaped by prefixing them
- # with a slash (<tt>\\</tt>).
- #
- # 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 = /[\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 <tt>nick!user@host</tt>
- #
- # 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:
- # * <tt>*!*@*</tt> refers to everybody
- # * <tt>*!someuser@somehost</tt> 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
- #
- 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
- 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 <code>Netmask</code>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 <code>User</code>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.__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 << ">"
- 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 ? topic.to_irc_channel_topic : 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 <code>Channel</code>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.__to_s__[0..-2]
- 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
-
- # 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
- 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 <code>Channel</code>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 <code>Channel</code>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 <code>User</code>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 <code>User</code>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 <code>User</code>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
-
+#-- 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 + + # 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 <tt>{}|^</tt> as the uppercase + # equivalents of # <tt>[]\~</tt>. + # + # This is however not the same on all IRC servers: some use standard ASCII + # casemapping, other do not consider <tt>^</tt> as the uppercase of + # <tt>~</tt> + # + 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 + warn "Casemap mismatch (#{self.inspect} != #{other.inspect})" + return false + end + 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 <tt>*</tt> stands for "any + # number of arbitrary characters", a <tt>?</tt> stands for "one and exactly + # one arbitrary character". These characters can be escaped by prefixing them + # with a slash (<tt>\\</tt>). + # + # 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 = /[\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 <tt>nick!user@host</tt> + # + # 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: + # * <tt>*!*@*</tt> refers to everybody + # * <tt>*!someuser@somehost</tt> 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 + # + 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 + 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 <code>Netmask</code>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 <code>User</code>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.__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 << ">" + 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 ? topic.to_irc_channel_topic : 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 <code>Channel</code>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.__to_s__[0..-2] + 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 + + # 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 + 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 <code>Channel</code>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 <code>Channel</code>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 <code>User</code>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 <code>User</code>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 <code>User</code>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 + diff --git a/lib/rbot/plugins/opmeh.rb b/lib/rbot/plugins/opmeh.rb index 2776de60..0702c906 100644 --- a/lib/rbot/plugins/opmeh.rb +++ b/lib/rbot/plugins/opmeh.rb @@ -1,19 +1,19 @@ -class OpMehPlugin < Plugin
-
- def help(plugin, topic="")
- return "opmeh <channel> => grant user ops in <channel>"
- end
-
- def privmsg(m)
- if(m.params)
- channel = m.params
- else
- channel = m.channel
- end
- target = m.sourcenick
- @bot.sendq("MODE #{channel} +o #{target}")
- m.okay
- end
-end
-plugin = OpMehPlugin.new
-plugin.register("opmeh")
+class OpMehPlugin < Plugin + + def help(plugin, topic="") + return "opmeh <channel> => grant user ops in <channel>" + end + + def privmsg(m) + if(m.params) + channel = m.params + else + channel = m.channel + end + target = m.sourcenick + @bot.sendq("MODE #{channel} +o #{target}") + m.okay + end +end +plugin = OpMehPlugin.new +plugin.register("opmeh") |