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