4 # :title: Voting plugin for rbot
5 # Author:: David Gadling <dave@toasterwaffles.com>
6 # Copyright:: (C) 2010 David Gadling
9 # Submit a poll question to a channel, wait for glorious outcome.
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)
17 attr_accessor :id, :author, :channel, :running, :ends_at, :started
18 attr_accessor :question, :answers, :duration, :voters, :outcome
20 def initialize(originating_message, question, answers, duration)
21 @author = originating_message.sourcenick
22 @channel = originating_message.channel
32 @answers[answer_index] = {
44 @ends_at = @started + @duration
49 return if @running == false
53 def record_vote(voter, choice)
55 return _("poll's closed!")
58 if @voters.has_key? voter
59 return _("you already voted for %{vote}!") % {
60 :vote => @voters[voter]
65 if @answers.has_key? choice
66 @answers[choice][:count] += 1
67 @voters[voter] = choice
69 return _("recorded your vote for %{choice}: %{value}") % {
71 :value => @answers[choice][:value]
74 return _("don't have an option %{choice}") % {
81 return Hash[:question => @question,
82 :answers => @answers.keys.collect { |a| [a, @answers[a][:value]] }
91 options = _("options are: ").dup
92 @answers.each { |letter, info|
93 options << "#{Bold}#{letter}#{NormalText}) #{info[:value]} "
99 class PollPlugin < Plugin
100 Config.register Config::IntegerValue.new('poll.max_concurrent_polls',
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',
108 :desc => _("Should we save results until we see the nick of the pollster?"))
110 def init_reg_entry(sym, default)
111 unless @registry.has_key?(sym)
112 @registry[sym] = default
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]
123 running.each do |id, poll|
124 duration = poll.ends_at - Time.now
126 # keep the poll running
127 @bot.timer.add_once(duration) { count_votes(poll.id) }
129 # the poll expired while the bot was out, end it
135 def authors_running_count(victim)
136 return @registry[:running].values.collect { |p|
137 if p.author == victim
142 }.inject(0) { |acc, v| acc + v }
146 author = m.sourcenick
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
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!")
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 }
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)
176 target_duration = @bot.config['poll.default_duration']
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
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
191 duration = Utils.parse_time_offset(target_duration) rescue nil
193 # TODO "until <some moment in time>"
198 m.reply _("I don't understand the time spec %{timespec}") % {
199 :timespec => "'#{time_word} #{target_duration}'"
204 poll = Poll.new(m, question, answers, duration)
206 m.reply _("new poll from %{author}: %{question}") % {
208 :question => "#{Bold}#{question}#{Bold}"
212 poll.id = @registry[:last_poll_id] + 1
214 command = _("poll vote %{id} <SINGLE-LETTER>") % {
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}"
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
232 def count_votes(poll_id)
233 poll = @registry[:running][poll_id]
236 return if poll == nil
239 dest = poll.channel ? poll.channel : poll.author
241 @bot.say(dest, _("let's find the answer to: %{q}") % {
242 :q => "#{Bold}#{poll.question}#{Bold}"
245 sorted = poll.answers.sort { |a,b| b[1][:count]<=>a[1][:count] }
247 winner_info = sorted.first
248 winner_info << sorted.inject(0) { |accum, choice| accum + choice[1][:count] }
250 if winner_info[2] == 0
251 poll.outcome = _("nobody voted")
253 if sorted[0][1][:count] == sorted[1][1][:count]
254 poll.outcome = _("no clear winner: ") +
258 _("'#{a[1][:value]}' got #{a[1][:count]} vote#{a[1][:count] > 1 ? 's' : ''}")
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],
273 @bot.say dest, poll.outcome
275 # Now that we're done, move it to the archives
276 archives = @registry[:archives]
277 archives[poll_id] = poll
278 @registry[:archives] = archives
280 # ... and take it out of the running list
281 running = @registry[:running]
282 running.delete(poll_id)
283 @registry[:running] = running
287 if @registry[:running].keys.length == 0
288 m.reply _("no polls running right now")
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
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 :(")
306 running = @registry[:running]
308 poll = running[poll_id]
309 result = poll.record_vote(m.sourcenick, params[:choice])
311 running[poll_id] = poll
312 @registry[:running] = running
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]]
323 m.reply _("sorry, couldn't find poll %{b}#%{id}%{b}") % {
330 to_reply = _("poll #%{id} was asked by %{bold}%{author}%{bold} in %{bold}%{channel}%{bold} %{started}.").dup
334 to_reply << _(" It's still running!")
335 if poll.voters.has_key? m.sourcenick
336 to_reply << _(" Be patient, it'll end %{end}")
338 to_reply << _(" You have until %{end} to vote if you haven't!")
339 options << " #{poll.options}"
342 outcome << " #{poll.outcome}"
345 m.reply((to_reply % {
347 :id => poll.id, :author => poll.author,
348 :channel => (poll.channel ? poll.channel : _("private")),
349 :started => poll.started,
351 }) + options + outcome)
354 def help(plugin,topic="")
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.")
361 _("poll list : Give some info about currently active polls")
363 _("poll info #{Bold}id#{Bold} : Get info about /results from a given poll")
365 _("poll vote #{Bold}id choice#{Bold} : Vote on the given poll with your choice")
367 _("Hold informative polls: poll start|list|info|vote")
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'