From 6ac97eacea0fbf893081f4544d6e55606a42ec0c Mon Sep 17 00:00:00 2001 From: Andy H Date: Sat, 29 Oct 2011 21:35:03 -0500 Subject: [PATCH 1/5] Added Markov learn by URL. --- data/rbot/plugins/markov.rb | 651 +++++------------------------------- 1 file changed, 81 insertions(+), 570 deletions(-) diff --git a/data/rbot/plugins/markov.rb b/data/rbot/plugins/markov.rb index 21c4d631..f9897d24 100755 --- a/data/rbot/plugins/markov.rb +++ b/data/rbot/plugins/markov.rb @@ -20,190 +20,10 @@ class MarkovPlugin < Plugin Config.register Config::ArrayValue.new('markov.ignore', :default => [], :desc => "Hostmasks and channel names markov should NOT learn from (e.g. idiot*!*@*, #privchan).") - Config.register Config::ArrayValue.new('markov.readonly', - :default => [], - :desc => "Hostmasks and channel names markov should NOT talk to (e.g. idiot*!*@*, #privchan).") Config.register Config::IntegerValue.new('markov.max_words', :default => 50, :validate => Proc.new { |v| (0..100).include? v }, :desc => "Maximum number of words the bot should put in a sentence") - Config.register Config::FloatValue.new('markov.learn_delay', - :default => 0.5, - :validate => Proc.new { |v| v >= 0 }, - :desc => "Time the learning thread spends sleeping after learning a line. If set to zero, learning from files can be very CPU intensive, but also faster.") - Config.register Config::IntegerValue.new('markov.delay', - :default => 5, - :validate => Proc.new { |v| v >= 0 }, - :desc => "Wait short time before contributing to conversation.") - Config.register Config::IntegerValue.new('markov.answer_addressed', - :default => 50, - :validate => Proc.new { |v| (0..100).include? v }, - :desc => "Probability of answer when addressed by nick") - Config.register Config::ArrayValue.new('markov.ignore_patterns', - :default => [], - :desc => "Ignore these word patterns") - - MARKER = :"\r\n" - - # upgrade a registry entry from 0.9.14 and earlier, converting the Arrays - # into Hashes of weights - def upgrade_entry(k, logfile) - logfile.puts "\t#{k.inspect}" - logfile.flush - logfile.fsync - - ar = @registry[k] - - # wipe the current key - @registry.delete(k) - - # discard empty keys - if ar.empty? - logfile.puts "\tEMPTY" - return - end - - # otherwise, proceed - logfile.puts "\t#{ar.inspect}" - - # re-encode key to UTF-8 and cleanup as needed - words = k.split.map do |w| - BasicUserMessage.strip_formatting( - @bot.socket.filter.in(w) - ).sub(/\001$/,'') - end - - # old import that failed to split properly? - if words.length == 1 and words.first.include? '/' - # split at the last / - unsplit = words.first - at = unsplit.rindex('/') - words = [unsplit[0,at], unsplit[at+1..-1]] - end - - # if any of the re-split/re-encoded words have spaces, - # or are empty, we would get a chain we can't convert, - # so drop it - if words.first.empty? or words.first.include?(' ') or - words.last.empty? or words.last.include?(' ') - logfile.puts "\tSKIPPED" - return - end - - # former unclean CTCP, we can't convert this - if words.first[0] == 1 - logfile.puts "\tSKIPPED" - return - end - - # nonword CTCP => SKIP - # someword CTCP => nonword someword - if words.last[0] == 1 - if words.first == "nonword" - logfile.puts "\tSKIPPED" - return - end - words.unshift MARKER - words.pop - end - - # intern the old keys - words.map! do |w| - ['nonword', MARKER].include?(w) ? MARKER : w.chomp("\001") - end - - newkey = words.join(' ') - logfile.puts "\t#{newkey.inspect}" - - # the new key exists already, so we want to merge - if k != newkey and @registry.key? newkey - ar2 = @registry[newkey] - logfile.puts "\tMERGE" - logfile.puts "\t\t#{ar2.inspect}" - ar.push(*ar2) - # and get rid of the key - @registry.delete(newkey) - end - - total = 0 - hash = Hash.new(0) - - @chains_mutex.synchronize do - if @chains.key? newkey - ar2 = @chains[newkey] - total += ar2.first - hash.update ar2.last - end - - ar.each do |word| - case word - when :nonword - # former marker - sym = MARKER - else - # we convert old words into UTF-8, cleanup, resplit if needed, - # and only get the first word. we may lose some data for old - # missplits, but this is the best we can do - w = BasicUserMessage.strip_formatting( - @bot.socket.filter.in(word).split.first - ) - case w - when /^\001\S+$/, "\001", "" - # former unclean CTCP or end of CTCP - next - else - # intern after clearing leftover end-of-actions if present - sym = w.chomp("\001") - end - end - hash[sym] += 1 - total += 1 - end - if hash.empty? - logfile.puts "\tSKIPPED" - return - end - logfile.puts "\t#{[total, hash].inspect}" - @chains[newkey] = [total, hash] - end - end - - def upgrade_registry - # we load all the keys and then iterate over this array because - # running each() on the registry and updating it at the same time - # doesn't work - keys = @registry.keys - # no registry, nothing to do - return if keys.empty? - - ki = 0 - log "starting markov database conversion thread (v1 to v2, #{keys.length} keys)" - - keys.each { |k| @upgrade_queue.push k } - @upgrade_queue.push nil - - @upgrade_thread = Thread.new do - logfile = File.open(@bot.path('markov-conversion.log'), 'a') - logfile.puts "=== conversion thread started #{Time.now} ===" - while k = @upgrade_queue.pop - ki += 1 - logfile.puts "Key #{ki} (#{@upgrade_queue.length} in queue):" - begin - upgrade_entry(k, logfile) - rescue Exception => e - logfile.puts "=== ERROR ===" - logfile.puts e.pretty_inspect - logfile.puts "=== EREND ===" - end - sleep @bot.config['markov.learn_delay'] unless @bot.config['markov.learn_delay'].zero? - end - logfile.puts "=== conversion thread stopped #{Time.now} ===" - logfile.close - end - @upgrade_thread.priority = -1 - end - - attr_accessor :chains def initialize super @@ -221,158 +41,54 @@ def initialize @bot.config['markov.ignore'] = @bot.config['markov.ignore_users'].dup @bot.config.delete('markov.ignore_users'.to_sym) end - - @chains = @registry.sub_registry('v2') - @chains.set_default([]) - @rchains = @registry.sub_registry('v2r') - @rchains.set_default([]) - @chains_mutex = Mutex.new - @rchains_mutex = Mutex.new - - @upgrade_queue = Queue.new - @upgrade_thread = nil - upgrade_registry - @learning_queue = Queue.new @learning_thread = Thread.new do while s = @learning_queue.pop - learn_line s - sleep @bot.config['markov.learn_delay'] unless @bot.config['markov.learn_delay'].zero? + learn s + sleep 0.5 end end @learning_thread.priority = -1 end def cleanup - if @upgrade_thread and @upgrade_thread.alive? - debug 'closing conversion thread' - @upgrade_queue.clear - @upgrade_queue.push nil - @upgrade_thread.join - debug 'conversion thread closed' - end - debug 'closing learning thread' - @learning_queue.clear @learning_queue.push nil @learning_thread.join debug 'learning thread closed' - @chains.close - @rchains.close - super - end - - # pick a word from the registry using the pair as key. - def pick_word(word1, word2=MARKER, chainz=@chains) - k = "#{word1} #{word2}" - return MARKER unless chainz.key? k - wordlist = chainz[k] - pick_word_from_list wordlist - end - - # pick a word from weighted hash - def pick_word_from_list(wordlist) - total = wordlist.first - hash = wordlist.last - return MARKER if total == 0 - return hash.keys.first if hash.length == 1 - hit = rand(total) - ret = MARKER - hash.each do |k, w| - hit -= w - if hit < 0 - ret = k - break - end - end - return ret end def generate_string(word1, word2) # limit to max of markov.max_words words - if word2 - output = [word1, word2] - else - output = word1 - keys = [] - @chains.each_key(output) do |key| - if key.downcase.include? output - keys << key - else - break - end - end - return nil if keys.empty? - output = keys[rand(keys.size)].split(/ /) - end - output = output.split(/ /) unless output.is_a? Array - input = [word1, word2] - while output.length < @bot.config['markov.max_words'] and (output.first != MARKER or output.last != MARKER) do - if output.last != MARKER - output << pick_word(output[-2], output[-1]) - end - if output.first != MARKER - output.insert 0, pick_word(output[0], output[1], @rchains) - end + output = word1 + " " + word2 + + # try to avoid :nonword in the first iteration + wordlist = @registry["#{word1} #{word2}"] + wordlist.delete(:nonword) + if not wordlist.empty? + word3 = wordlist[rand(wordlist.length)] + output = output + " " + word3 + word1, word2 = word2, word3 end - output.delete MARKER - if output == input - nil - else - output.join(" ") + + (@bot.config['markov.max_words'] - 1).times do + wordlist = @registry["#{word1} #{word2}"] + break if wordlist.empty? + word3 = wordlist[rand(wordlist.length)] + break if word3 == :nonword + output = output + " " + word3 + word1, word2 = word2, word3 end + return output end def help(plugin, topic="") - topic, subtopic = topic.split - - case topic - when "delay" - "markov delay => Set message delay" - when "ignore" - case subtopic - when "add" - "markov ignore add => ignore a hostmask or a channel" - when "list" - "markov ignore list => show ignored hostmasks and channels" - when "remove" - "markov ignore remove => unignore a hostmask or channel" - else - "ignore hostmasks or channels -- topics: add, remove, list" - end - when "readonly" - case subtopic - when "add" - "markov readonly add => read-only a hostmask or a channel" - when "list" - "markov readonly list => show read-only hostmasks and channels" - when "remove" - "markov readonly remove => unreadonly a hostmask or channel" - else - "restrict hostmasks or channels to read only -- topics: add, remove, list" - end - when "status" - "markov status => show if markov is enabled, probability and amount of messages in queue for learning" - when "probability" - "markov probability [] => set the % chance of rbot responding to input, or display the current probability" - when "chat" - case subtopic - when "about" - "markov chat about [] => talk about or riff on a word pair (if possible)" - else - "markov chat => try to say something intelligent" - end - else - "markov plugin: listens to chat to build a markov chain, with which it can (perhaps) attempt to (inanely) contribute to 'discussion'. Sort of.. Will get a *lot* better after listening to a lot of chat. Usage: 'chat' to attempt to say something relevant to the last line of chat, if it can -- help topics: ignore, readonly, delay, status, probability, chat, chat about" - end + "markov plugin: listens to chat to build a markov chain, with which it can (perhaps) attempt to (inanely) contribute to 'discussion'. Sort of.. Will get a *lot* better after listening to a lot of chat. usage: 'markov' to attempt to say something relevant to the last line of chat, if it can. other options to markov: 'ignore' => ignore a hostmask (accept no input), 'status' => show current status, 'probability []' => set the % chance of rbot responding to input, or display the current probability, 'chat' => try and say something intelligent, 'chat about ' => riff on a word pair (if possible)" end - def clean_message(m) - str = m.plainmessage.dup - str =~ /^(\S+)([:,;])/ - if $1 and m.target.is_a? Irc::Channel and m.target.user_nicks.include? $1.downcase - str.gsub!(/^(\S+)([:,;])\s+/, "") - end + def clean_str(s) + str = s.dup + str.gsub!(/^\S+[:,;]/, "") str.gsub!(/\s{2,}/, ' ') # fix for two or more spaces return str.strip end @@ -383,21 +99,15 @@ def probability? def status(m,params) if @bot.config['markov.enabled'] - reply = _("markov is currently enabled, %{p}% chance of chipping in") % { :p => probability? } - l = @learning_queue.length - reply << (_(", %{l} messages in queue") % {:l => l}) if l > 0 - l = @upgrade_queue.length - reply << (_(", %{l} chains to upgrade") % {:l => l}) if l > 0 + m.reply "markov is currently enabled, #{probability?}% chance of chipping in, #{@learning_queue.length} messages in queue" else - reply = _("markov is currently disabled") + m.reply "markov is currently disabled" end - m.reply reply end def ignore?(m=nil) return false unless m - return true if m.private? - return true if m.prefixed? + return true if m.address? or m.private? @bot.config['markov.ignore'].each do |mask| return true if m.channel.downcase == mask.downcase return true if m.source.matches?(mask) @@ -405,74 +115,34 @@ def ignore?(m=nil) return false end - def readonly?(m=nil) - return false unless m - @bot.config['markov.readonly'].each do |mask| - return true if m.channel.downcase == mask.downcase - return true if m.source.matches?(mask) - end - return false - end - def ignore(m, params) action = params[:action] user = params[:option] case action - when 'remove' + when 'remove': if @bot.config['markov.ignore'].include? user s = @bot.config['markov.ignore'] s.delete user @bot.config['ignore'] = s - m.reply _("%{u} removed") % { :u => user } + m.reply "#{user} removed" else - m.reply _("not found in list") + m.reply "not found in list" end - when 'add' + when 'add': if user if @bot.config['markov.ignore'].include?(user) - m.reply _("%{u} already in list") % { :u => user } + m.reply "#{user} already in list" else @bot.config['markov.ignore'] = @bot.config['markov.ignore'].push user - m.reply _("%{u} added to markov ignore list") % { :u => user } + m.reply "#{user} added to markov ignore list" end else - m.reply _("give the name of a person or channel to ignore") + m.reply "give the name of a person or channel to ignore" end - when 'list' - m.reply _("I'm ignoring %{ignored}") % { :ignored => @bot.config['markov.ignore'].join(", ") } + when 'list': + m.reply "I'm ignoring #{@bot.config['markov.ignore'].join(", ")}" else - m.reply _("have markov ignore the input from a hostmask or a channel. usage: markov ignore add ; markov ignore remove ; markov ignore list") - end - end - - def readonly(m, params) - action = params[:action] - user = params[:option] - case action - when 'remove' - if @bot.config['markov.readonly'].include? user - s = @bot.config['markov.readonly'] - s.delete user - @bot.config['markov.readonly'] = s - m.reply _("%{u} removed") % { :u => user } - else - m.reply _("not found in list") - end - when 'add' - if user - if @bot.config['markov.readonly'].include?(user) - m.reply _("%{u} already in list") % { :u => user } - else - @bot.config['markov.readonly'] = @bot.config['markov.readonly'].push user - m.reply _("%{u} added to markov readonly list") % { :u => user } - end - else - m.reply _("give the name of a person or channel to read only") - end - when 'list' - m.reply _("I'm only reading %{readonly}") % { :readonly => @bot.config['markov.readonly'].join(", ") } - else - m.reply _("have markov not answer to input from a hostmask or a channel. usage: markov readonly add ; markov readonly remove ; markov readonly list") + m.reply "have markov ignore the input from a hostmask or a channel. usage: markov ignore add ; markov ignore remove ; markov ignore list" end end @@ -495,262 +165,103 @@ def disable(m, params) m.okay end - def should_talk(m) + def should_talk return false unless @bot.config['markov.enabled'] - prob = m.address? ? @bot.config['markov.answer_addressed'] : probability? + prob = probability? return true if prob > rand(100) return false end - # Generates all sequence pairs from array - # seq_pairs [1,2,3,4] == [ [1,2], [2,3], [3,4]] - def seq_pairs(arr) - res = [] - 0.upto(arr.size-2) do |i| - res << [arr[i], arr[i+1]] - end - res - end - - def set_delay(m, params) - if params[:delay] == "off" - @bot.config["markov.delay"] = 0 - m.okay - elsif !params[:delay] - m.reply _("Message delay is %{delay}" % { :delay => @bot.config["markov.delay"]}) - else - @bot.config["markov.delay"] = params[:delay].to_i - m.okay - end - end - - def reply_delay(m, line) - m.replied = true - if @bot.config['markov.delay'] > 0 - @bot.timer.add_once(1 + rand(@bot.config['markov.delay'])) { - m.reply line, :nick => false, :to => :public - } - else - m.reply line, :nick => false, :to => :public - end + def delay + 1 + rand(5) end def random_markov(m, message) - return unless should_talk(m) - - words = clean_message(m).split(/\s+/) - if words.length < 2 - line = generate_string words.first, nil - - if line and message.index(line) != 0 - reply_delay m, line - return - end - else - pairs = seq_pairs(words).sort_by { rand } - pairs.each do |word1, word2| - line = generate_string(word1, word2) - if line and message.index(line) != 0 - reply_delay m, line - return - end - end - words.sort_by { rand }.each do |word| - line = generate_string word.first, nil - if line and message.index(line) != 0 - reply_delay m, line - return - end - end - end + return unless should_talk + + word1, word2 = message.split(/\s+/) + return unless word1 and word2 + line = generate_string(word1, word2) + return unless line + # we do nothing if the line we return is just an initial substring + # of the line we received + return if message.index(line) == 0 + @bot.timer.add_once(delay) { + m.plainreply line + } end def chat(m, params) line = generate_string(params[:seed1], params[:seed2]) - if line and line != [params[:seed1], params[:seed2]].compact.join(" ") - m.reply line + if line != "#{params[:seed1]} #{params[:seed2]}" + m.reply line else - m.reply _("I can't :(") + m.reply "I can't :(" end end def rand_chat(m, params) # pick a random pair from the db and go from there - word1, word2 = MARKER, MARKER + word1, word2 = :nonword, :nonword output = Array.new - @bot.config['markov.max_words'].times do - word3 = pick_word(word1, word2) - break if word3 == MARKER + 50.times do + wordlist = @registry["#{word1} #{word2}"] + break if wordlist.empty? + word3 = wordlist[rand(wordlist.length)] + break if word3 == :nonword output << word3 word1, word2 = word2, word3 end if output.length > 1 m.reply output.join(" ") else - m.reply _("I can't :(") + m.reply "I can't :(" end end - - def learn(*lines) - lines.each { |l| @learning_queue.push l } - end - - def unreplied(m) + + def message(m) return if ignore? m # in channel message, the kind we are interested in - message = m.plainmessage + message = clean_str m.plainmessage if m.action? message = "#{m.sourcenick} #{message}" end - - random_markov(m, message) unless readonly? m or m.replied? - learn clean_message(m) - end - - - def learn_triplet(word1, word2, word3) - k = "#{word1} #{word2}" - rk = "#{word2} #{word3}" - @chains_mutex.synchronize do - total = 0 - hash = Hash.new(0) - if @chains.key? k - t2, h2 = @chains[k] - total += t2 - hash.update h2 - end - hash[word3] += 1 - total += 1 - @chains[k] = [total, hash] - end - @rchains_mutex.synchronize do - # Reverse - total = 0 - hash = Hash.new(0) - if @rchains.key? rk - t2, h2 = @rchains[rk] - total += t2 - hash.update h2 - end - hash[word1] += 1 - total += 1 - @rchains[rk] = [total, hash] - end + + @learning_queue.push message + random_markov(m, message) unless m.replied? end - - def learn_line(message) - # debug "learning #{message.inspect}" - wordlist = message.strip.split(/\s+/).reject do |w| - @bot.config['markov.ignore_patterns'].map do |pat| - w =~ Regexp.new(pat.to_s) - end.select{|v| v}.size != 0 - end + def learn(message) + # debug "learning #{message}" + wordlist = message.split(/\s+/) return unless wordlist.length >= 2 - word1, word2 = MARKER, MARKER - wordlist << MARKER + word1, word2 = :nonword, :nonword wordlist.each do |word3| - learn_triplet(word1, word2, word3.to_sym) + k = "#{word1} #{word2}" + @registry[k] = @registry[k].push(word3) word1, word2 = word2, word3 end + k = "#{word1} #{word2}" + @registry[k] = @registry[k].push(:nonword) end - # TODO allow learning from URLs - def learn_from(m, params) - begin - path = params[:file] - file = File.open(path, "r") - pattern = params[:pattern].empty? ? nil : Regexp.new(params[:pattern].to_s) - rescue Errno::ENOENT - m.reply _("no such file") - return - end - - if file.eof? - m.reply _("the file is empty!") - return - end - - if params[:testing] - lines = [] - range = case params[:lines] - when /^\d+\.\.\d+$/ - Range.new(*params[:lines].split("..").map { |e| e.to_i }) - when /^\d+$/ - Range.new(1, params[:lines].to_i) - else - Range.new(1, [@bot.config['send.max_lines'], 3].max) - end - - file.each do |line| - next unless file.lineno >= range.begin - lines << line.chomp - break if file.lineno == range.end - end - - lines = lines.map do |l| - pattern ? l.scan(pattern).to_s : l - end.reject { |e| e.empty? } - - if pattern - unless lines.empty? - m.reply _("example matches for that pattern at lines %{range} include: %{lines}") % { - :lines => lines.map { |e| Underline+e+Underline }.join(", "), - :range => range.to_s - } - else - m.reply _("the pattern doesn't match anything at lines %{range}") % { - :range => range.to_s - } - end - else - m.reply _("learning from the file without a pattern would learn, for example: ") - lines.each { |l| m.reply l } - end - - return - end - - if pattern - file.each { |l| learn(l.scan(pattern).to_s) } - else - file.each { |l| learn(l.chomp) } - end - + def learn_url(m, params) + Utils.safe_exec("w3m -cols 10000 -dump '#{params[:url]}'").split(/[\r\n]+/).each {|l| @learning_queue.push l } m.okay end - - def stats(m, params) - m.reply "Markov status: chains: #{@chains.length} forward, #{@rchains.length} reverse, queued phrases: #{@learning_queue.size}" - end - end plugin = MarkovPlugin.new -plugin.map 'markov delay :delay', :action => "set_delay" -plugin.map 'markov delay', :action => "set_delay" plugin.map 'markov ignore :action :option', :action => "ignore" plugin.map 'markov ignore :action', :action => "ignore" plugin.map 'markov ignore', :action => "ignore" -plugin.map 'markov readonly :action :option', :action => "readonly" -plugin.map 'markov readonly :action', :action => "readonly" -plugin.map 'markov readonly', :action => "readonly" plugin.map 'markov enable', :action => "enable" plugin.map 'markov disable', :action => "disable" plugin.map 'markov status', :action => "status" -plugin.map 'markov stats', :action => "stats" -plugin.map 'chat about :seed1 [:seed2]', :action => "chat" +plugin.map 'chat about :seed1 :seed2', :action => "chat" plugin.map 'chat', :action => "rand_chat" plugin.map 'markov probability [:probability]', :action => "probability", :requirements => {:probability => /^\d+%?$/} -plugin.map 'markov learn from :file [:testing [:lines lines]] [using pattern *pattern]', :action => "learn_from", :thread => true, - :requirements => { - :testing => /^testing$/, - :lines => /^(?:\d+\.\.\d+|\d+)$/ } - -plugin.default_auth('ignore', false) -plugin.default_auth('probability', false) -plugin.default_auth('learn', false) - +plugin.map 'markov learn :url', :action => "learn_url" From c36ad77f98e4412c63ef8a9cab0874f168d1b559 Mon Sep 17 00:00:00 2001 From: Andy H Date: Sat, 29 Oct 2011 21:35:52 -0500 Subject: [PATCH 2/5] Revert "Added Markov learn by URL." This reverts commit 6ac97eacea0fbf893081f4544d6e55606a42ec0c. --- data/rbot/plugins/markov.rb | 651 +++++++++++++++++++++++++++++++----- 1 file changed, 570 insertions(+), 81 deletions(-) diff --git a/data/rbot/plugins/markov.rb b/data/rbot/plugins/markov.rb index f9897d24..21c4d631 100755 --- a/data/rbot/plugins/markov.rb +++ b/data/rbot/plugins/markov.rb @@ -20,10 +20,190 @@ class MarkovPlugin < Plugin Config.register Config::ArrayValue.new('markov.ignore', :default => [], :desc => "Hostmasks and channel names markov should NOT learn from (e.g. idiot*!*@*, #privchan).") + Config.register Config::ArrayValue.new('markov.readonly', + :default => [], + :desc => "Hostmasks and channel names markov should NOT talk to (e.g. idiot*!*@*, #privchan).") Config.register Config::IntegerValue.new('markov.max_words', :default => 50, :validate => Proc.new { |v| (0..100).include? v }, :desc => "Maximum number of words the bot should put in a sentence") + Config.register Config::FloatValue.new('markov.learn_delay', + :default => 0.5, + :validate => Proc.new { |v| v >= 0 }, + :desc => "Time the learning thread spends sleeping after learning a line. If set to zero, learning from files can be very CPU intensive, but also faster.") + Config.register Config::IntegerValue.new('markov.delay', + :default => 5, + :validate => Proc.new { |v| v >= 0 }, + :desc => "Wait short time before contributing to conversation.") + Config.register Config::IntegerValue.new('markov.answer_addressed', + :default => 50, + :validate => Proc.new { |v| (0..100).include? v }, + :desc => "Probability of answer when addressed by nick") + Config.register Config::ArrayValue.new('markov.ignore_patterns', + :default => [], + :desc => "Ignore these word patterns") + + MARKER = :"\r\n" + + # upgrade a registry entry from 0.9.14 and earlier, converting the Arrays + # into Hashes of weights + def upgrade_entry(k, logfile) + logfile.puts "\t#{k.inspect}" + logfile.flush + logfile.fsync + + ar = @registry[k] + + # wipe the current key + @registry.delete(k) + + # discard empty keys + if ar.empty? + logfile.puts "\tEMPTY" + return + end + + # otherwise, proceed + logfile.puts "\t#{ar.inspect}" + + # re-encode key to UTF-8 and cleanup as needed + words = k.split.map do |w| + BasicUserMessage.strip_formatting( + @bot.socket.filter.in(w) + ).sub(/\001$/,'') + end + + # old import that failed to split properly? + if words.length == 1 and words.first.include? '/' + # split at the last / + unsplit = words.first + at = unsplit.rindex('/') + words = [unsplit[0,at], unsplit[at+1..-1]] + end + + # if any of the re-split/re-encoded words have spaces, + # or are empty, we would get a chain we can't convert, + # so drop it + if words.first.empty? or words.first.include?(' ') or + words.last.empty? or words.last.include?(' ') + logfile.puts "\tSKIPPED" + return + end + + # former unclean CTCP, we can't convert this + if words.first[0] == 1 + logfile.puts "\tSKIPPED" + return + end + + # nonword CTCP => SKIP + # someword CTCP => nonword someword + if words.last[0] == 1 + if words.first == "nonword" + logfile.puts "\tSKIPPED" + return + end + words.unshift MARKER + words.pop + end + + # intern the old keys + words.map! do |w| + ['nonword', MARKER].include?(w) ? MARKER : w.chomp("\001") + end + + newkey = words.join(' ') + logfile.puts "\t#{newkey.inspect}" + + # the new key exists already, so we want to merge + if k != newkey and @registry.key? newkey + ar2 = @registry[newkey] + logfile.puts "\tMERGE" + logfile.puts "\t\t#{ar2.inspect}" + ar.push(*ar2) + # and get rid of the key + @registry.delete(newkey) + end + + total = 0 + hash = Hash.new(0) + + @chains_mutex.synchronize do + if @chains.key? newkey + ar2 = @chains[newkey] + total += ar2.first + hash.update ar2.last + end + + ar.each do |word| + case word + when :nonword + # former marker + sym = MARKER + else + # we convert old words into UTF-8, cleanup, resplit if needed, + # and only get the first word. we may lose some data for old + # missplits, but this is the best we can do + w = BasicUserMessage.strip_formatting( + @bot.socket.filter.in(word).split.first + ) + case w + when /^\001\S+$/, "\001", "" + # former unclean CTCP or end of CTCP + next + else + # intern after clearing leftover end-of-actions if present + sym = w.chomp("\001") + end + end + hash[sym] += 1 + total += 1 + end + if hash.empty? + logfile.puts "\tSKIPPED" + return + end + logfile.puts "\t#{[total, hash].inspect}" + @chains[newkey] = [total, hash] + end + end + + def upgrade_registry + # we load all the keys and then iterate over this array because + # running each() on the registry and updating it at the same time + # doesn't work + keys = @registry.keys + # no registry, nothing to do + return if keys.empty? + + ki = 0 + log "starting markov database conversion thread (v1 to v2, #{keys.length} keys)" + + keys.each { |k| @upgrade_queue.push k } + @upgrade_queue.push nil + + @upgrade_thread = Thread.new do + logfile = File.open(@bot.path('markov-conversion.log'), 'a') + logfile.puts "=== conversion thread started #{Time.now} ===" + while k = @upgrade_queue.pop + ki += 1 + logfile.puts "Key #{ki} (#{@upgrade_queue.length} in queue):" + begin + upgrade_entry(k, logfile) + rescue Exception => e + logfile.puts "=== ERROR ===" + logfile.puts e.pretty_inspect + logfile.puts "=== EREND ===" + end + sleep @bot.config['markov.learn_delay'] unless @bot.config['markov.learn_delay'].zero? + end + logfile.puts "=== conversion thread stopped #{Time.now} ===" + logfile.close + end + @upgrade_thread.priority = -1 + end + + attr_accessor :chains def initialize super @@ -41,54 +221,158 @@ def initialize @bot.config['markov.ignore'] = @bot.config['markov.ignore_users'].dup @bot.config.delete('markov.ignore_users'.to_sym) end + + @chains = @registry.sub_registry('v2') + @chains.set_default([]) + @rchains = @registry.sub_registry('v2r') + @rchains.set_default([]) + @chains_mutex = Mutex.new + @rchains_mutex = Mutex.new + + @upgrade_queue = Queue.new + @upgrade_thread = nil + upgrade_registry + @learning_queue = Queue.new @learning_thread = Thread.new do while s = @learning_queue.pop - learn s - sleep 0.5 + learn_line s + sleep @bot.config['markov.learn_delay'] unless @bot.config['markov.learn_delay'].zero? end end @learning_thread.priority = -1 end def cleanup + if @upgrade_thread and @upgrade_thread.alive? + debug 'closing conversion thread' + @upgrade_queue.clear + @upgrade_queue.push nil + @upgrade_thread.join + debug 'conversion thread closed' + end + debug 'closing learning thread' + @learning_queue.clear @learning_queue.push nil @learning_thread.join debug 'learning thread closed' + @chains.close + @rchains.close + super + end + + # pick a word from the registry using the pair as key. + def pick_word(word1, word2=MARKER, chainz=@chains) + k = "#{word1} #{word2}" + return MARKER unless chainz.key? k + wordlist = chainz[k] + pick_word_from_list wordlist + end + + # pick a word from weighted hash + def pick_word_from_list(wordlist) + total = wordlist.first + hash = wordlist.last + return MARKER if total == 0 + return hash.keys.first if hash.length == 1 + hit = rand(total) + ret = MARKER + hash.each do |k, w| + hit -= w + if hit < 0 + ret = k + break + end + end + return ret end def generate_string(word1, word2) # limit to max of markov.max_words words - output = word1 + " " + word2 - - # try to avoid :nonword in the first iteration - wordlist = @registry["#{word1} #{word2}"] - wordlist.delete(:nonword) - if not wordlist.empty? - word3 = wordlist[rand(wordlist.length)] - output = output + " " + word3 - word1, word2 = word2, word3 + if word2 + output = [word1, word2] + else + output = word1 + keys = [] + @chains.each_key(output) do |key| + if key.downcase.include? output + keys << key + else + break + end + end + return nil if keys.empty? + output = keys[rand(keys.size)].split(/ /) end - - (@bot.config['markov.max_words'] - 1).times do - wordlist = @registry["#{word1} #{word2}"] - break if wordlist.empty? - word3 = wordlist[rand(wordlist.length)] - break if word3 == :nonword - output = output + " " + word3 - word1, word2 = word2, word3 + output = output.split(/ /) unless output.is_a? Array + input = [word1, word2] + while output.length < @bot.config['markov.max_words'] and (output.first != MARKER or output.last != MARKER) do + if output.last != MARKER + output << pick_word(output[-2], output[-1]) + end + if output.first != MARKER + output.insert 0, pick_word(output[0], output[1], @rchains) + end + end + output.delete MARKER + if output == input + nil + else + output.join(" ") end - return output end def help(plugin, topic="") - "markov plugin: listens to chat to build a markov chain, with which it can (perhaps) attempt to (inanely) contribute to 'discussion'. Sort of.. Will get a *lot* better after listening to a lot of chat. usage: 'markov' to attempt to say something relevant to the last line of chat, if it can. other options to markov: 'ignore' => ignore a hostmask (accept no input), 'status' => show current status, 'probability []' => set the % chance of rbot responding to input, or display the current probability, 'chat' => try and say something intelligent, 'chat about ' => riff on a word pair (if possible)" + topic, subtopic = topic.split + + case topic + when "delay" + "markov delay => Set message delay" + when "ignore" + case subtopic + when "add" + "markov ignore add => ignore a hostmask or a channel" + when "list" + "markov ignore list => show ignored hostmasks and channels" + when "remove" + "markov ignore remove => unignore a hostmask or channel" + else + "ignore hostmasks or channels -- topics: add, remove, list" + end + when "readonly" + case subtopic + when "add" + "markov readonly add => read-only a hostmask or a channel" + when "list" + "markov readonly list => show read-only hostmasks and channels" + when "remove" + "markov readonly remove => unreadonly a hostmask or channel" + else + "restrict hostmasks or channels to read only -- topics: add, remove, list" + end + when "status" + "markov status => show if markov is enabled, probability and amount of messages in queue for learning" + when "probability" + "markov probability [] => set the % chance of rbot responding to input, or display the current probability" + when "chat" + case subtopic + when "about" + "markov chat about [] => talk about or riff on a word pair (if possible)" + else + "markov chat => try to say something intelligent" + end + else + "markov plugin: listens to chat to build a markov chain, with which it can (perhaps) attempt to (inanely) contribute to 'discussion'. Sort of.. Will get a *lot* better after listening to a lot of chat. Usage: 'chat' to attempt to say something relevant to the last line of chat, if it can -- help topics: ignore, readonly, delay, status, probability, chat, chat about" + end end - def clean_str(s) - str = s.dup - str.gsub!(/^\S+[:,;]/, "") + def clean_message(m) + str = m.plainmessage.dup + str =~ /^(\S+)([:,;])/ + if $1 and m.target.is_a? Irc::Channel and m.target.user_nicks.include? $1.downcase + str.gsub!(/^(\S+)([:,;])\s+/, "") + end str.gsub!(/\s{2,}/, ' ') # fix for two or more spaces return str.strip end @@ -99,15 +383,21 @@ def probability? def status(m,params) if @bot.config['markov.enabled'] - m.reply "markov is currently enabled, #{probability?}% chance of chipping in, #{@learning_queue.length} messages in queue" + reply = _("markov is currently enabled, %{p}% chance of chipping in") % { :p => probability? } + l = @learning_queue.length + reply << (_(", %{l} messages in queue") % {:l => l}) if l > 0 + l = @upgrade_queue.length + reply << (_(", %{l} chains to upgrade") % {:l => l}) if l > 0 else - m.reply "markov is currently disabled" + reply = _("markov is currently disabled") end + m.reply reply end def ignore?(m=nil) return false unless m - return true if m.address? or m.private? + return true if m.private? + return true if m.prefixed? @bot.config['markov.ignore'].each do |mask| return true if m.channel.downcase == mask.downcase return true if m.source.matches?(mask) @@ -115,34 +405,74 @@ def ignore?(m=nil) return false end + def readonly?(m=nil) + return false unless m + @bot.config['markov.readonly'].each do |mask| + return true if m.channel.downcase == mask.downcase + return true if m.source.matches?(mask) + end + return false + end + def ignore(m, params) action = params[:action] user = params[:option] case action - when 'remove': + when 'remove' if @bot.config['markov.ignore'].include? user s = @bot.config['markov.ignore'] s.delete user @bot.config['ignore'] = s - m.reply "#{user} removed" + m.reply _("%{u} removed") % { :u => user } else - m.reply "not found in list" + m.reply _("not found in list") end - when 'add': + when 'add' if user if @bot.config['markov.ignore'].include?(user) - m.reply "#{user} already in list" + m.reply _("%{u} already in list") % { :u => user } else @bot.config['markov.ignore'] = @bot.config['markov.ignore'].push user - m.reply "#{user} added to markov ignore list" + m.reply _("%{u} added to markov ignore list") % { :u => user } end else - m.reply "give the name of a person or channel to ignore" + m.reply _("give the name of a person or channel to ignore") end - when 'list': - m.reply "I'm ignoring #{@bot.config['markov.ignore'].join(", ")}" + when 'list' + m.reply _("I'm ignoring %{ignored}") % { :ignored => @bot.config['markov.ignore'].join(", ") } else - m.reply "have markov ignore the input from a hostmask or a channel. usage: markov ignore add ; markov ignore remove ; markov ignore list" + m.reply _("have markov ignore the input from a hostmask or a channel. usage: markov ignore add ; markov ignore remove ; markov ignore list") + end + end + + def readonly(m, params) + action = params[:action] + user = params[:option] + case action + when 'remove' + if @bot.config['markov.readonly'].include? user + s = @bot.config['markov.readonly'] + s.delete user + @bot.config['markov.readonly'] = s + m.reply _("%{u} removed") % { :u => user } + else + m.reply _("not found in list") + end + when 'add' + if user + if @bot.config['markov.readonly'].include?(user) + m.reply _("%{u} already in list") % { :u => user } + else + @bot.config['markov.readonly'] = @bot.config['markov.readonly'].push user + m.reply _("%{u} added to markov readonly list") % { :u => user } + end + else + m.reply _("give the name of a person or channel to read only") + end + when 'list' + m.reply _("I'm only reading %{readonly}") % { :readonly => @bot.config['markov.readonly'].join(", ") } + else + m.reply _("have markov not answer to input from a hostmask or a channel. usage: markov readonly add ; markov readonly remove ; markov readonly list") end end @@ -165,103 +495,262 @@ def disable(m, params) m.okay end - def should_talk + def should_talk(m) return false unless @bot.config['markov.enabled'] - prob = probability? + prob = m.address? ? @bot.config['markov.answer_addressed'] : probability? return true if prob > rand(100) return false end - def delay - 1 + rand(5) + # Generates all sequence pairs from array + # seq_pairs [1,2,3,4] == [ [1,2], [2,3], [3,4]] + def seq_pairs(arr) + res = [] + 0.upto(arr.size-2) do |i| + res << [arr[i], arr[i+1]] + end + res + end + + def set_delay(m, params) + if params[:delay] == "off" + @bot.config["markov.delay"] = 0 + m.okay + elsif !params[:delay] + m.reply _("Message delay is %{delay}" % { :delay => @bot.config["markov.delay"]}) + else + @bot.config["markov.delay"] = params[:delay].to_i + m.okay + end + end + + def reply_delay(m, line) + m.replied = true + if @bot.config['markov.delay'] > 0 + @bot.timer.add_once(1 + rand(@bot.config['markov.delay'])) { + m.reply line, :nick => false, :to => :public + } + else + m.reply line, :nick => false, :to => :public + end end def random_markov(m, message) - return unless should_talk - - word1, word2 = message.split(/\s+/) - return unless word1 and word2 - line = generate_string(word1, word2) - return unless line - # we do nothing if the line we return is just an initial substring - # of the line we received - return if message.index(line) == 0 - @bot.timer.add_once(delay) { - m.plainreply line - } + return unless should_talk(m) + + words = clean_message(m).split(/\s+/) + if words.length < 2 + line = generate_string words.first, nil + + if line and message.index(line) != 0 + reply_delay m, line + return + end + else + pairs = seq_pairs(words).sort_by { rand } + pairs.each do |word1, word2| + line = generate_string(word1, word2) + if line and message.index(line) != 0 + reply_delay m, line + return + end + end + words.sort_by { rand }.each do |word| + line = generate_string word.first, nil + if line and message.index(line) != 0 + reply_delay m, line + return + end + end + end end def chat(m, params) line = generate_string(params[:seed1], params[:seed2]) - if line != "#{params[:seed1]} #{params[:seed2]}" - m.reply line + if line and line != [params[:seed1], params[:seed2]].compact.join(" ") + m.reply line else - m.reply "I can't :(" + m.reply _("I can't :(") end end def rand_chat(m, params) # pick a random pair from the db and go from there - word1, word2 = :nonword, :nonword + word1, word2 = MARKER, MARKER output = Array.new - 50.times do - wordlist = @registry["#{word1} #{word2}"] - break if wordlist.empty? - word3 = wordlist[rand(wordlist.length)] - break if word3 == :nonword + @bot.config['markov.max_words'].times do + word3 = pick_word(word1, word2) + break if word3 == MARKER output << word3 word1, word2 = word2, word3 end if output.length > 1 m.reply output.join(" ") else - m.reply "I can't :(" + m.reply _("I can't :(") end end - - def message(m) + + def learn(*lines) + lines.each { |l| @learning_queue.push l } + end + + def unreplied(m) return if ignore? m # in channel message, the kind we are interested in - message = clean_str m.plainmessage + message = m.plainmessage if m.action? message = "#{m.sourcenick} #{message}" end - - @learning_queue.push message - random_markov(m, message) unless m.replied? + + random_markov(m, message) unless readonly? m or m.replied? + learn clean_message(m) + end + + + def learn_triplet(word1, word2, word3) + k = "#{word1} #{word2}" + rk = "#{word2} #{word3}" + @chains_mutex.synchronize do + total = 0 + hash = Hash.new(0) + if @chains.key? k + t2, h2 = @chains[k] + total += t2 + hash.update h2 + end + hash[word3] += 1 + total += 1 + @chains[k] = [total, hash] + end + @rchains_mutex.synchronize do + # Reverse + total = 0 + hash = Hash.new(0) + if @rchains.key? rk + t2, h2 = @rchains[rk] + total += t2 + hash.update h2 + end + hash[word1] += 1 + total += 1 + @rchains[rk] = [total, hash] + end end - def learn(message) - # debug "learning #{message}" - wordlist = message.split(/\s+/) + + def learn_line(message) + # debug "learning #{message.inspect}" + wordlist = message.strip.split(/\s+/).reject do |w| + @bot.config['markov.ignore_patterns'].map do |pat| + w =~ Regexp.new(pat.to_s) + end.select{|v| v}.size != 0 + end return unless wordlist.length >= 2 - word1, word2 = :nonword, :nonword + word1, word2 = MARKER, MARKER + wordlist << MARKER wordlist.each do |word3| - k = "#{word1} #{word2}" - @registry[k] = @registry[k].push(word3) + learn_triplet(word1, word2, word3.to_sym) word1, word2 = word2, word3 end - k = "#{word1} #{word2}" - @registry[k] = @registry[k].push(:nonword) end - def learn_url(m, params) - Utils.safe_exec("w3m -cols 10000 -dump '#{params[:url]}'").split(/[\r\n]+/).each {|l| @learning_queue.push l } + # TODO allow learning from URLs + def learn_from(m, params) + begin + path = params[:file] + file = File.open(path, "r") + pattern = params[:pattern].empty? ? nil : Regexp.new(params[:pattern].to_s) + rescue Errno::ENOENT + m.reply _("no such file") + return + end + + if file.eof? + m.reply _("the file is empty!") + return + end + + if params[:testing] + lines = [] + range = case params[:lines] + when /^\d+\.\.\d+$/ + Range.new(*params[:lines].split("..").map { |e| e.to_i }) + when /^\d+$/ + Range.new(1, params[:lines].to_i) + else + Range.new(1, [@bot.config['send.max_lines'], 3].max) + end + + file.each do |line| + next unless file.lineno >= range.begin + lines << line.chomp + break if file.lineno == range.end + end + + lines = lines.map do |l| + pattern ? l.scan(pattern).to_s : l + end.reject { |e| e.empty? } + + if pattern + unless lines.empty? + m.reply _("example matches for that pattern at lines %{range} include: %{lines}") % { + :lines => lines.map { |e| Underline+e+Underline }.join(", "), + :range => range.to_s + } + else + m.reply _("the pattern doesn't match anything at lines %{range}") % { + :range => range.to_s + } + end + else + m.reply _("learning from the file without a pattern would learn, for example: ") + lines.each { |l| m.reply l } + end + + return + end + + if pattern + file.each { |l| learn(l.scan(pattern).to_s) } + else + file.each { |l| learn(l.chomp) } + end + m.okay end + + def stats(m, params) + m.reply "Markov status: chains: #{@chains.length} forward, #{@rchains.length} reverse, queued phrases: #{@learning_queue.size}" + end + end plugin = MarkovPlugin.new +plugin.map 'markov delay :delay', :action => "set_delay" +plugin.map 'markov delay', :action => "set_delay" plugin.map 'markov ignore :action :option', :action => "ignore" plugin.map 'markov ignore :action', :action => "ignore" plugin.map 'markov ignore', :action => "ignore" +plugin.map 'markov readonly :action :option', :action => "readonly" +plugin.map 'markov readonly :action', :action => "readonly" +plugin.map 'markov readonly', :action => "readonly" plugin.map 'markov enable', :action => "enable" plugin.map 'markov disable', :action => "disable" plugin.map 'markov status', :action => "status" -plugin.map 'chat about :seed1 :seed2', :action => "chat" +plugin.map 'markov stats', :action => "stats" +plugin.map 'chat about :seed1 [:seed2]', :action => "chat" plugin.map 'chat', :action => "rand_chat" plugin.map 'markov probability [:probability]', :action => "probability", :requirements => {:probability => /^\d+%?$/} -plugin.map 'markov learn :url', :action => "learn_url" +plugin.map 'markov learn from :file [:testing [:lines lines]] [using pattern *pattern]', :action => "learn_from", :thread => true, + :requirements => { + :testing => /^testing$/, + :lines => /^(?:\d+\.\.\d+|\d+)$/ } + +plugin.default_auth('ignore', false) +plugin.default_auth('probability', false) +plugin.default_auth('learn', false) + From b1799532608b130c7ed339df25947a1183ae1d03 Mon Sep 17 00:00:00 2001 From: Andy H Date: Sat, 29 Oct 2011 21:37:23 -0500 Subject: [PATCH 3/5] Fixed Markov Learn by URL for current rbot version. --- data/rbot/plugins/markov.rb | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/data/rbot/plugins/markov.rb b/data/rbot/plugins/markov.rb index 21c4d631..c7cc6b47 100755 --- a/data/rbot/plugins/markov.rb +++ b/data/rbot/plugins/markov.rb @@ -726,6 +726,11 @@ def stats(m, params) m.reply "Markov status: chains: #{@chains.length} forward, #{@rchains.length} reverse, queued phrases: #{@learning_queue.size}" end + def learn_url(m, params) + Utils.safe_exec("w3m -cols 10000 -dump '#{params[:url]}'").split(/[\r\n]+/).each {|l| @learning_queue.push l } + m.okay + end + end plugin = MarkovPlugin.new From 694920abbd32a0a1259b07706e9890a5f0a9a49a Mon Sep 17 00:00:00 2001 From: Andy Hill Date: Thu, 22 Mar 2012 07:57:41 -0500 Subject: [PATCH 4/5] Added markov learn_url as markov learn changed with new release --- data/rbot/plugins/markov.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/data/rbot/plugins/markov.rb b/data/rbot/plugins/markov.rb index c7cc6b47..89d9fe74 100755 --- a/data/rbot/plugins/markov.rb +++ b/data/rbot/plugins/markov.rb @@ -735,6 +735,7 @@ def learn_url(m, params) plugin = MarkovPlugin.new plugin.map 'markov delay :delay', :action => "set_delay" +plugin.map 'markov learn_url :url', :action => "learn_url" plugin.map 'markov delay', :action => "set_delay" plugin.map 'markov ignore :action :option', :action => "ignore" plugin.map 'markov ignore :action', :action => "ignore" From c94cdcfb34dc7bbe63d16e97e1298377d708596e Mon Sep 17 00:00:00 2001 From: David Gadling Date: Fri, 5 Sep 2008 16:18:22 -0700 Subject: [PATCH 5/5] + (games) An acrophobia game for your channel Acrophobia for IRC. More information on acrophobia is available on wikipedia. Includes a Hall of Fame (scoreboard). Unfortunately doesn't work on a per-channel basis since some secrecy is required. Signed-off-by: Andy Hill --- data/rbot/plugins/games/acro.rb | 447 ++++++++++++++++++++++++++++++++ 1 file changed, 447 insertions(+) create mode 100644 data/rbot/plugins/games/acro.rb diff --git a/data/rbot/plugins/games/acro.rb b/data/rbot/plugins/games/acro.rb new file mode 100644 index 00000000..8adc11c6 --- /dev/null +++ b/data/rbot/plugins/games/acro.rb @@ -0,0 +1,447 @@ +#-- vim:ts=4:et +#++ +# +# :title: Acrophobia plugin for rbot +# Author:: David Gadling +# Copyright:: (C) 2008 David Gadling +# License:: BSD +# +# Submit acronym expansions and vote on the best! +# +# FIXME: Currently only works in one channel at a time since people /msg the +# bot with suggestions and votes. Not sure how to get around that, if at all +# + +# Control codes +Norm = "\002\00302" +Hi = "\002\00313" +Clear = "\017" + +MIN_ACRO_LENGTH = 3 +MAX_ACRO_LENGTH = 5 +BASE_SUB_TIME = 60 +SUB_TIME_DELTA = 5 +VOTE_TIME = 60 +BAD_LETTERS = Array['Q', 'X', 'V'] +FORBIDDEN_LETTERS = Array['Z'] +SPEED_POINTS = 2 + +class AcroPlugin < Plugin + def initialize() + super + @answers = Hash.new + @voters = Array.new + @ballot = Array.new + @submitOrder = Array.new + @playing = false + @submitting = false + @voting = false + @acro = "" + @len = 0 + @channel = "" + @currTimer = nil + @time = 0 + @roundsLeft = 0 + end + + + # Reset everything for a new round of the game + def reset() + @acro = "" + @ballot.clear + @answers.clear + @voters.clear + @submitOrder.clear + @playing = true + @voting = false + @submitting = false + @len = 0 + @firstAnswerer = "" + @time = 0 + end + + + # return help, natch + def help(plugin, topic="") + case topic + when 'play' + _("acro => submit your expansion of the given acronym") + when 'vote' + _("vote => submit your vote for the best expansion") + when 'start' + _("start => start playing acrophobia!") + when 'stop' + _("stop => stop playing acrophobia") + when 'stats' + _("stats => find out how many rounds you have won and your total " + + "accumulated points in addition to how many rounds you played") + when 'hof' + _("hof [wins|points|submissions] => view the hall of fame based " + + "on any of the keys mentioned. Default is wins") + else + _("acro: acrophobia plugin. topics: acro, vote, start, stop, stats") + end + end + + + # Start a game by picking a new acronym and letting the rest take over + def startGame(m, params) + if @playing + m.reply("I'm currently playing in #{@channel}, come join us!") + return + end + @playing = true + @channel = m.channel + @roundsLeft = params[:roundCount].to_i + @bot.say(@channel, "#{Norm}Starting #{Hi}#{@roundsLeft}#{Clear}#{Norm}" + + " rounds of acrophobia") + pickNewAcronym() + end + + + # Starting a new round of a possibly new game. Pick an acronym and tell people + # about it. + def pickNewAcronym() + if @roundsLeft == 0 then stopGame(nil, nil) and return end + reset() + @len = MIN_ACRO_LENGTH + rand(MAX_ACRO_LENGTH - MIN_ACRO_LENGTH) + 1.upto(@len) do |n| + nextLetter = sprintf("%c", 65+rand(26)) + # 90% chance of picking a new letter if a bad letter was chosen + if BAD_LETTERS.include?(nextLetter) and rand(101) > 10 + nextLetter = sprintf("%c", 65+rand(26)) + end + @acro += nextLetter + end + + # FIXME: Should we add time for longer expansions? joe says "no". + @time = BASE_SUB_TIME #+ ((@len - MIN_ACRO_LENGTH) * SUB_TIME_DELTA) + @time /= 2 + + @submitting = true + @bot.say(@channel, "#{Norm}The current acronym is: #{Hi}#{@acro}#{Clear}") + @bot.say(@channel, "#{Norm}You have #{Hi}#{@time*2}#{Clear}#{Norm} " + + "seconds to submit expansions via #{Hi}/msg #{@bot.nick} acro " + + "#{Clear}") + @currTimer = @bot.timer.add_once(@time) { + warning + } + end + + + # Give everybody a warning about how much time they have left + def warning() + @bot.say(@channel, "#{Hi}#{@time}#{Clear}#{Norm} seconds left!") + @currTimer = @bot.timer.add_once(@time) { + transitionToVoting + } + end + + + # Stop accepting submissions, tell people what they can vote on, and start + # accepting votes + def transitionToVoting() + @submitting = false + @bot.say(@channel, "#{Norm}Pencils down, time is up!") + + if @answers.keys.length < 2 + @bot.say(@channel, "#{Norm}Not enough submissions, stopping.") + stopGame(nil, nil) + return + end + + @bot.say(@channel, "#{Norm}Vote for one of the following and send it to "+ + "me via #{Hi}/msg #{@bot.nick} vote ") + + @answers.each { |k,v| + @ballot.push({:candidate=>k, :votes=>0, :submitter=>v}) + } + 1.upto(@ballot.length) { |n| + @bot.say(@channel, "#{Hi}#{n}. #{@ballot[n-1][:candidate]}") + } + + @voting = true + @bot.say(@channel, "#{Norm}You have #{Hi}#{VOTE_TIME}#{Norm} " + + "seconds to vote") + @currTimer = @bot.timer.add_once(VOTE_TIME) { tallyVotes } + end + + + # Tally up all the votes, assign points, and crown a winner + def tallyVotes() + @voting = false + @bot.say(@channel, "#{Norm}Thanks for voting, let's look at the results!") + + # Score = + # Number of votes + + # (First answer + less than 25 points = + 2 speed points) + # (Most votes = + bonus points) + + winner = "" + winningVotes = 0 + @ballot.each do |n| + user = @registry[n[:submitter]] || Hash.new + user[:wins] = user.fetch(:wins, 0) + user[:submissions] = user.fetch(:submissions, 0) + 1 + user[:totalPoints] = user.fetch(:totalPoints, 0) + n[:votes] + @bot.say(@channel, "#{Hi}#{n[:submitter]}#{Clear}#{Norm}'s answer of " + + "#{Hi}#{n[:candidate]}#{Clear}#{Norm} received " + + "#{Hi}#{n[:votes]}#{Clear}#{Norm} votes") + if n[:votes] > winningVotes + winner = n[:submitter] + winningVotes = n[:votes] + elsif n[:votes] == winningVotes + winner = "" + end + if n[:submitter] == @submitOrder[0] + user[:totalPoints] = user.fetch(:totalPoints, 0) + SPEED_POINTS + @bot.say(@channel, "#{Hi}#{n[:submitter]}#{Clear}#{Norm} gets "+ + "#{Hi}#{SPEED_POINTS}#{Clear}#{Norm} speed points") + end + @registry[n[:submitter]] = user + end + + if winner != "" + ourWinner = @registry[winner] + ourWinner[:totalPoints] = ourWinner.fetch(:totalPoints, 0) + @len + ourWinner[:wins] = ourWinner.fetch(:wins, 0) + 1 + @bot.say(@channel, "#{Hi}#{winner}#{Clear}#{Norm} receives " + + "#{Hi}#{@len}#{Clear}#{Norm} bonus points " + + "for winning the round") + @registry[winner] = ourWinner + end + + @roundsLeft = @roundsLeft - 1 + pickNewAcronym + end + + + # Figure out who the winnar is! + def hof(m, params) + fool = m.sourcenick + tmpKey = params[:key].to_s + if tmpKey == "points" + tmpKey = "totalPoints" + end + targetKey = tmpKey.to_sym + m.reply("Checking out the #{params[:key].to_s} HoF...") + tmp = @registry.to_hash + sorted = tmp.sort { |a,b| b[1][targetKey] <=> a[1][targetKey] } + + winnersLeft = 5 + + winners = [] + sorted.each do |player| + winners << "#{player[0]} has #{player[1][targetKey]}" + winnersLeft -= 1 + if winnersLeft == 0 + break + end + end + m.reply(winners.join(" | ")) + end + + # Say we're done playing and kill any timers + def stopGame(m, params) + return if ! @playing + @bot.say(@channel, "#{Norm}Well that was fun") + reset() + @playing = false + @channel = "" + @bot.timer.remove(@currTimer) + end + + + # Make sure that we're playing a game AND accepting votes (errors otherwise). + # Make sure the user hasn't already voted in this round + # Record their vote + def handleVote(m, params) + fool = m.sourcenick + if !@playing + @bot.notice(fool, "We're not playing acrophobia right now. " + + "Maybe you should start a game?") + return + end + if !@voting + @bot.notice(fool, "I'm not taking votes right now. " + + "Maybe you wanted to submit an answer?") + return + end + vote = params[:input].to_s + + if vote !~ /^\d+$/ + @bot.notice(fool, "You need to vote for a #{Hi}number") + return + end + + if @voters.include?(m.sourcenick.to_s) + @bot.notice(fool, "Stop trying to 'vote early, vote often'!") + return + end + + vote = vote.to_i + + if vote > @ballot.length or vote < 1 + @bot.notice(fool, "#{Hi}##{vote}#{Clear} wasn't an option!") + return + end + + @voters.push(m.sourcenick.to_s) + @ballot[vote-1][:votes] += 1 + @bot.notice(fool, "Your vote for #{Hi}##{vote}#{Clear} " + + "(#{@ballot[vote-1][:candidate]}) has been recorded") + end + + + # Accept acronym expansion submissions + # Make sure they're the right length, and compress to the right acronym + def handleSubmission(m, params) + fool = m.sourcenick + if !@playing + @bot.notice(fool, "We're not playing acrophobia right now. " + + "Maybe you should start a game?") + return + end + if !@submitting + @bot.notice(fool, "I'm not taking acronym submissions right now. " + + "Maybe you wanted to vote?") + return + end + sender = m.sourcenick.to_s + + # First get all the cases where they can't submit or we can't accept their + # answer for various reasons: + # 1) It's the wrong length (too short or too long) + # 2) It doesn't compress to the acronym we're looking for + # 3) Somebody else already submitted it! + # 4) They submitted the acronym, but spaced out + submission = params[:input].to_s + pieces = submission.gsub(/\s\[[a-zA-Z]{1,3}\]\s/, ' ').split(' ') + + whine = "I can't accept #{Hi}#{submission}#{Clear}" + err = "" + err = "long" if pieces.length() > @len + err = "short" if pieces.length() < @len + + if err != "" + @bot.notice(fool, "#{whine}, it's too #{err}. " + + "Try something #{Hi}#{@len}#{Clear} words long.") + return + end + + badCharacters = 0 + pieces.each { |chunk| badCharacters += chunk.gsub(/[\w\s]/, '').length } + + if badCharacters > 0 + @bot.notice(fool, "#{whine}, it's got bad characters in it. Only A-Z and numbers allowed!") + return + end + + # For each piece of what they submitted, take the first character and make + # it uppercase, appending it to the acronym version of what they submitted + submittedAcronym = "" + pieces.each { |chunk| submittedAcronym += chunk.split('')[0].upcase } + + # If what they submitted doesn't compresses to the acronym we're looking + # for, tell them as much + if submittedAcronym != @acro + @bot.notice(fool, "#{whine}, it doesn't match the target acronym " + + "(#{Hi}#{@acro}#{Clear})") + return + end + + if @answers.has_key?(submission) + @bot.notice(fool, "#{whine}, somebody already submitted it!") + return + end + + if submission.gsub(/\s/, '').upcase == @acro + @bot.notice(fool, "#{whine}, you submitted the acronym itself!") + return + end + + # If they've submitted before, they lose their speed bonus and their old + # answer goes away + if @submitOrder.include?(sender) + @submitOrder.delete(sender) + @answers.delete(@answers.index(sender)) + end + + @submitOrder.push(sender) + @answers[submission] = sender + if @firstAnswerer == "" + @firstAnswerer = sender + end + @bot.notice(fool, "Your submission (#{Hi}#{submission}#{Clear}) " + + "has been accepted") + @bot.say(@channel, "#{@answers.keys.length} submissions accepted") + end + + + # Generic processing of private messages + # If we're not playing, suggest they start a game + # If we're taking submissions, route appropriately + # If we're voting, route appropriately + def processPrivate(m, params) + if ! @playing + @bot.notice(fool, "We're not playing acrophobia right now. " + + "Maybe you should start a game?") + return + end + + handleVote(m, params) if @voting + handleSubmission(m, params) if @submitting + end + + + # Simple processing of public messages. If we're playing or voting, tell them + # it's secret. If we're not doing either of those, don't respond. + def processPublic(m, params) + if @playing + if @voting + m.reply("I'm only listening for votes via /msg right now!") + else + m.reply("I'm only listening for submissions via /msg right now!") + end + end + end + + + # Show my stats to everybody. Yay e-penis! + def showMyStats(m, params) + foo = @registry[m.sourcenick.to_s] + + if foo == nil + m.reply "You don't appear to have played acrophobia before!" + else + ourReply = "#{Norm}Submissions: #{Hi}#{foo[:submissions]}#{Clear}" + + "#{Norm} | Wins: #{Hi}#{foo[:wins]}#{Clear}" + + "#{Norm} | Points: #{Hi}#{foo[:totalPoints]}#{Clear}" + if m.channel + ourReply = "#{m.sourcenick.to_s}: #{ourReply}" + m.reply ourReply + else + @bot.reply(m.sourcenick, ourReply) + end + end + end + + + # Stop the game if we get told to cleanup + def cleanup() + stopGame(nil, nil) + end + +end + +# This plugin routing stuff is awesome and creepy at the same time. +# The wildcarded routes need to go last so that more specific commands (e.g. +# hof) get matched first +plugin = AcroPlugin.new +plugin.map 'acro start :roundCount', :action => 'startGame', :defaults => {:roundCount => 5} +plugin.map 'acro stop', :action => 'stopGame' +plugin.map 'acro stats', :action => 'showMyStats' +plugin.map 'acro hof :key', :action => 'hof', :defaults => {:key => "wins"}, :requirements => {:key => /^(?:wins|points|submissions)$/} +plugin.map 'vote *input', :action => 'handleVote', :public => false +plugin.map 'acro *input', :action => 'processPrivate', :public => false +plugin.map 'acro *input', :action => 'processPublic', :private => false