]> git.netwichtig.de Git - user/henk/code/ruby/rbot.git/blob - data/rbot/plugins/poll.rb
poll plugin: message fixes
[user/henk/code/ruby/rbot.git] / data / rbot / plugins / poll.rb
1 #-- vim:ts=2:et:sw=2
2 #++
3 #
4 # :title: Voting plugin for rbot
5 # Author:: David Gadling <dave@toasterwaffles.com>
6 # Copyright:: (C) 2010 David Gadling
7 # License:: BSD
8 #
9 # Submit a poll question to a channel, wait for glorious outcome.
10 #
11
12 class ::Poll
13   attr_accessor :id, :author, :channel, :running, :ends_at, :started
14   attr_accessor :question, :answers, :duration, :voters, :outcome
15
16   def initialize(originating_message, question, answers, duration)
17     @author = originating_message.sourcenick
18     @channel = originating_message.channel
19     @question = question
20     @running = false
21     @duration = duration
22
23     @answers = Hash.new
24     @voters  = Hash.new
25
26     answer_index = "A"
27     answers.each do |ans|
28       @answers[answer_index] = {
29         :value => ans,
30         :count => 0
31       }
32       answer_index.next!
33     end
34   end
35
36   def start!
37     return if @running
38
39     @started = Time.now
40     @ends_at = @started + @duration
41     @running = true
42   end
43
44   def stop!
45     return if @running == false
46     @running = false
47   end
48
49   def record_vote(voter, choice)
50     if @running == false
51       return _("Poll's closed!")
52     end
53
54     if @voters.has_key? voter
55       return _("You already voted for #{@voters[voter]}!")
56     end
57
58     choice.upcase!
59     if @answers.has_key? choice
60       @answers[choice][:count] += 1
61       @voters[voter] = choice
62
63       return _("Recorded your vote for #{choice}: #{@answers[choice][:value]}")
64     else
65       return _("Don't have an option #{choice}")
66     end
67   end
68
69   def printing_values
70     return Hash[:question => @question,
71             :answers => @answers.keys.collect { |a| [a, @answers[a][:value]] }
72     ]
73   end
74
75   def to_s
76     return @question
77   end
78
79   def options
80     options = _("Options are: ")
81     @answers.each { |letter, info|
82       options << "#{Bold}#{letter}#{NormalText}) #{info[:value]} "
83     }
84     return options
85   end
86 end
87
88 class PollPlugin < Plugin
89   Config.register Config::IntegerValue.new('poll.max_concurrent_polls',
90     :default => 2,
91     :desc => _("How many polls a user can have running at once"))
92   Config.register Config::StringValue.new('poll.default_duration',
93     :default => "2 minutes",
94     :desc => _("How long a poll will accept answers, by default."))
95   Config.register Config::BooleanValue.new('poll.save_results',
96     :default => true,
97     :desc => _("Should we save results until we see the nick of the pollster?"))
98
99   def init_reg_entry(sym, default)
100     unless @registry.has_key?(sym)
101       @registry[sym] = default
102     end
103   end
104
105   def initialize()
106     super
107     init_reg_entry :running, Hash.new
108     init_reg_entry :archives, Hash.new
109     init_reg_entry :last_poll_id, 0
110   end
111
112   def authors_running_count(victim)
113     return @registry[:running].values.collect { |p|
114       if p.author == victim
115         1
116       else
117         0
118       end
119     }.inject(0) { |acc, v| acc + v }
120   end
121
122   def start(m, params)
123     author = m.sourcenick
124     chan = m.channel
125
126     max_concurrent = @bot.config['poll.max_concurrent_polls']
127     if authors_running_count(author) == max_concurrent
128       m.reply _("Sorry, you're already at the limit (#{max_concurrent}) polls")
129       return
130     end
131
132     input_blob = params[:blob].to_s.strip
133     quote_character = input_blob[0,1]
134     chunks = input_blob.split(/#{quote_character}\s+#{quote_character}/)
135     if chunks.length <= 2
136       m.reply _("This isn't a dictatorship!")
137       return
138     end
139
140     # grab the question, removing the leading quote character
141     question = chunks[0][1..-1].strip
142     question << "?" unless question[-1,1] == "?"
143     answers = chunks[1..-1].map { |a| a.strip }
144
145     # if the last answer terminates with a quote character,
146     # there is no time specification, so strip the quote character
147     # and assume default duration
148     if answers.last[-1,1] == quote_character
149       answers.last.chomp!(quote_character)
150       time_word = :for
151       target_duration = @bot.config['poll.default_duration']
152     else
153       last_quote = answers.last.rindex(quote_character)
154       time_spec = answers.last[(last_quote+1)..-1].strip
155       answers.last[last_quote..-1] = String.new
156       answers.last.strip!
157       # now answers.last is really the (cleaned-up) last answer,
158       # while time_spec holds the (cleaned-up) time spec, which
159       # should start with 'for' or 'until'
160       time_word, target_duration = time_spec.split(/\s+/, 2)
161       time_word = time_word.strip.intern rescue nil
162     end
163
164     case time_word
165     when :for
166       duration = Utils.parse_time_offset(target_duration) rescue nil
167     else
168       # TODO "until <some moment in time>"
169       duration = nil
170     end
171
172     unless duration
173       m.reply _("I don't understand the time spec %{timespec}") % {
174         :timespec => "'#{time_word} #{target_duration}'"
175       }
176       return
177     end
178
179     poll = Poll.new(m, question, answers, duration)
180
181     m.reply _("New poll from #{author}: #{Bold}#{question}#{NormalText}")
182     m.reply poll.options
183
184     poll.id = @registry[:last_poll_id] + 1
185     poll.start!
186     command = _("poll vote #{poll.id} <SINGLE-LETTER>")
187     m.reply _("You have #{Bold}#{target_duration}#{NormalText} to: " +
188             "#{Bold}/msg #{@bot.nick} #{command}#{NormalText} or " +
189             "#{Bold}#{@bot.config['core.address_prefix']}#{command}#{NormalText} ")
190
191     running = @registry[:running]
192     running[poll.id] = poll
193     @registry[:running] = running
194     @bot.timer.add_once(duration) { count_votes(poll.id) }
195     @registry[:last_poll_id] = poll.id
196   end
197
198   def count_votes(poll_id)
199     poll = @registry[:running][poll_id]
200
201     # Hrm, it vanished!
202     return if poll == nil
203     poll.stop!
204
205     @bot.say(poll.channel, _("Let's find the answer to: #{Bold}#{poll.question}#{NormalText}"))
206
207     sorted = poll.answers.sort { |a,b| b[1][:count]<=>a[1][:count] }
208
209     winner_info = sorted.first
210     winner_info << sorted.inject(0) { |accum, choice| accum + choice[1][:count] }
211
212     if winner_info[2] == 0
213       poll.outcome = _("Nobody voted")
214     else
215       if sorted[0][1][:count] == sorted[1][1][:count]
216         poll.outcome = _("No clear winner: ") +
217           sorted.select { |a|
218             a[1][:count] > 0
219           }.collect { |a|
220             _("'#{a[1][:value]}' got #{a[1][:count]} vote#{a[1][:count] > 1 ? 's' : ''}")
221           }.join(", ")
222       else
223         winning_pct = "%3.0f%%" % [ 100 * (winner_info[1][:count] / winner_info[2]) ]
224         poll.outcome = _("The winner was choice #{winner_info[0]}: " +
225                        "'#{winner_info[1][:value]}' " +
226                        "with #{winner_info[1][:count]} " +
227                        "vote#{winner_info[1][:count] > 1 ? 's' : ''} (#{winning_pct})")
228       end
229     end
230
231     @bot.say poll.channel, poll.outcome
232
233     # Now that we're done, move it to the archives
234     archives = @registry[:archives]
235     archives[poll_id] = poll
236     @registry[:archives] = archives
237
238     # ... and take it out of the running list
239     running = @registry[:running]
240     running.delete(poll_id)
241     @registry[:running] = running
242   end
243
244   def list(m, params)
245     if @registry[:running].keys.length == 0
246       m.reply _("No polls running right now")
247       return
248     end
249
250     @registry[:running].each { |id, p|
251       m.reply _("#{p.author}'s poll \"#{p.question}\" (id ##{p.id}) runs until #{p.ends_at}")
252     }
253   end
254
255   def record_vote(m, params)
256     poll_id = params[:id].to_i
257     if @registry[:running].has_key?(poll_id) == false
258       m.reply _("I don't have poll ##{poll_id} running :(")
259       return
260     end
261
262     running = @registry[:running]
263
264     poll = running[poll_id]
265     result = poll.record_vote(m.sourcenick, params[:choice])
266
267     running[poll_id] = poll
268     @registry[:running] = running
269     m.reply result
270   end
271
272   def info(m, params)
273     params[:id] = params[:id].to_i
274     if @registry[:running].has_key? params[:id]
275       poll = @registry[:running][params[:id]]
276     elsif @registry[:archives].has_key? params[:id]
277       poll = @registry[:archives][params[:id]]
278     else
279       m.reply _("Sorry, couldn't find poll ##{Bold}#{params[:id]}#{NormalText}")
280       return
281     end
282
283     to_reply = _("Poll ##{poll.id} was asked by #{Bold}#{poll.author}#{NormalText} " +
284                  "in #{Bold}#{poll.channel}#{NormalText} #{poll.started}.")
285     if poll.running
286       to_reply << _(" It's still running!")
287       if poll.voters.has_key? m.sourcenick
288         to_reply << _(" Be patient, it'll end #{poll.ends_at}")
289       else
290         to_reply << _(" You have until #{poll.ends_at} to vote if you haven't!")
291         to_reply << " #{poll.options}"
292       end
293     else
294       to_reply << " #{poll.outcome}"
295     end
296
297     m.reply _(to_reply)
298   end
299
300   def help(plugin,topic="")
301     case topic
302     when "start"
303       _("poll start 'my question' 'answer1' 'answer2' ['answer3' ...] " +
304         "[for 5 minutes] : Start a poll for the given duration. " +
305         "If you don't specify a duration the default will be used.")
306     when "list"
307       _("poll list : Give some info about currently active polls")
308     when "info"
309       _("poll info #{Bold}id#{Bold} : Get info about /results from a given poll")
310     when "vote"
311       _("poll vote #{Bold}id choice#{Bold} : Vote on the given poll with your choice")
312     else
313       _("Hold informative polls: poll start|list|info|vote")
314     end
315   end
316 end
317
318 plugin = PollPlugin.new
319 plugin.map 'poll start *blob', :action => 'start'
320 plugin.map 'poll list', :action => 'list'
321 plugin.map 'poll info :id', :action => 'info'
322 plugin.map 'poll vote :id :choice', :action => 'record_vote', :threaded => true