add votemail
authorBenjamin Mako Hill <mako@atdot.cc>
Mon, 24 May 2010 21:39:59 +0000 (17:39 -0400)
committerBenjamin Mako Hill <mako@atdot.cc>
Mon, 24 May 2010 21:39:59 +0000 (17:39 -0400)
misc/votemail [new file with mode: 0755]

diff --git a/misc/votemail b/misc/votemail
new file mode 100755 (executable)
index 0000000..d928126
--- /dev/null
@@ -0,0 +1,347 @@
+#!/usr/bin/env ruby
+
+## bring in the rubyvote library
+require '/home/mako/bin/lib/rubyvote'
+
+# list of support election methods
+@voting_methods = { 'plurality' => PluralityVote,
+                    'approval' => ApprovalVote,
+                   'condorcet' => CondorcetVote,
+                   'ssd' => CloneproofSSDVote,
+                   'borda' => BordaVote,
+                   'runoff' => InstantRunoffVote }
+
+@default_method = 'ssd'
+
+# ruby mail handling
+require 'rmail'
+require 'net/smtp'
+@from = "Selectricity Anywhere <vote@selectricity.org>" # from address
+
+## create an active record connection to an SQLite3 database where we
+## will store all of the vote data 
+require 'sqlite3'
+require '/usr/share/rails/activerecord/lib/active_record.rb'
+
+ActiveRecord::Base.establish_connection(
+  :adapter=> 'sqlite3',
+  :dbfile=> '/home/mako/.votemail.db')
+
+class Election < ActiveRecord::Base
+  has_many :votes
+end
+
+class Vote < ActiveRecord::Base
+  belongs_to :election
+end
+
+# expire old elections
+def cleanup 
+  Election.find_all(["active = 1"]).each do |election|
+    if Time.now - election.startdate > 1.hour
+      election.active = 0
+      election.save
+    end
+  end
+end
+
+# create a new election object
+def new_election(name, candidate_string=nil, options={})
+
+  # check to see if the name is in use
+  if Election.find_all(["active = 1 and name = ?", name]).length > 0 
+    reply "Vote name \"%s\" in use. Try later or with new name." % name
+    return false
+  end
+
+  # check to see if it is a verboten name
+  if name == 'new' or name == 'create' or name =~ /^res/
+    reply "Sorry. You can't create a vote with that name."
+    return false
+  end
+
+  # see if there are any repeat candidates
+  if candidate_string.split(//).any? {|i| candidate_string.split(//).grep(i).length > 1}
+    reply "The choice list cannot contain repeated characters." 
+    return false
+  end
+  # make sure there is a candidate list
+  if candidate_string.split(//).length < 2
+    reply "A vote needs at least two candidates"
+    return false
+  end
+
+  # otherwise, we have what we need to create and populate the new
+  # election
+  election = Election.new
+  election.name = name
+  election.candidate_string = candidate_string.split(//).sort.join("")
+  election.startdate = Time.now
+  election.active = 1
+
+  # set the voting method
+  if options.has_key?('method')
+    if @voting_methods.has_key?(options['method'])
+      election.voting_method = options['method']
+    else
+      reply "Unsupported voting method. Vote not created."
+      return false
+    end
+  else
+    election.voting_method = @default_method
+  end 
+
+  # save the info string on candidates if anything is provided
+  if options.has_key?('candidates')
+     
+    # generate the info string
+    election.info = candidate_string.split(//).collect do |c|
+      if options['candidates'].has_key?(c)
+        description = options['candidates'][c]
+      else
+        description = 'unknown'
+      end
+      [c.upcase, description].join(":")
+    end.join(", ")
+  end
+
+  if election.save
+    reply ("Vote created. Vote like: \"%s %s\". " +
+           "Arrange capital letters in order of voter preference. " +
+           "Send \"res %s\" for results.") % \
+         [name, candidate_string.upcase, name]
+    return true
+  else
+    reply "Failure creating vote."
+    return false
+  end
+end
+
+def register_vote(name=nil, votestring=nil)
+
+  election = Election.find_all(["active = 1 and name = ?", name])[0]
+
+  # check that the vote string doesn't contain duplicates
+  if votestring.split(//).any? {|i| votestring.split(//).grep(i).length > 1}
+    reply "The choice list cannot contain repeated characters."
+    return false
+
+  # OR extras 
+  elsif votestring.split(//).any? {|i| not election.candidate_string.split(//).include?(i)}
+    reply "The choice list must only include these characters: %s" % election.candidate_string
+    return false
+  end
+  
+  unless election.voting_method == 'plurality' or election.voting_method == 'approval'
+  # OR doesn't list all of the necessary options
+    if votestring.length != election.candidate_string.length
+      reply "You must list ALL of these choices: \"%s\"" % election.candidate_string
+      return false
+    end
+  end
+
+  # if this person has voted already, blow away the vote
+  Vote.find_all(["election_id = ? and email = ?", election.id, @email]).each do |vote|
+    vote.destroy
+  end
+
+  # create the new vote and populate the string
+  vote = Vote.new
+  vote.votestring = votestring
+  vote.email = @email
+  vote.election = election
+
+  # attach the vote to the election
+  if vote.save
+    reply "Vote recorded successfully as: %s" % vote.votestring.upcase
+    return true
+  else
+    reply "Failed to register your vote."
+    return false
+  end 
+end
+
+def results(name=nil)
+
+  # ensure that we can find a vote by the name
+  if Election.find_all(["active = 1 and name = ?", name]).length != 1
+    reply "Sorry! I cannot find active vote by that name."
+    return false
+  else
+    election = Election.find_all(["active = 1 and name = ?", name])[0]
+  end
+
+  if election.votes.length == 0
+    reply "I have recorded no votes for that election."
+    return false
+  end
+  
+  # build the tally of the number of votes
+  tally = Array.new
+  election.votes.each do |vote|
+    tally <<  vote.votestring.split(//)
+  end
+
+  result = @voting_methods[election.voting_method].new(tally).result
+  
+  if result.winner?
+    if result.winners.length == 1
+      message = "The winner is: %s" % result.winners[0].upcase
+    else 
+      message = "The winners are: %s" % result.winners.join(", ")
+    end
+  else
+    message = "There was no winner."
+  end
+  
+  message += (" (Total votes: %s)" % election.votes.length )
+  reply(message)
+  return true
+end
+
+def provide_info(name=nil)
+  if not name or Election.find_all(["active = 1 and name = ?", name]).length != 1
+    reply "Sorry! I cannot find an active vote by that name."
+    return false
+  else
+    election = Election.find_all(["active = 1 and name = ?", name])[0]
+  end
+
+  if election.info
+    info_string = election.info
+  else
+    info_string = election.candidate_string.upcase.split(//).join(", ")
+  end
+  
+  reply ("Choices include %s. Please list ALL and reply with " +
+         "\"%s %s\" with %s being in the order of your preference.") \
+         % [ info_string, election.name,
+            election.candidate_string.upcase, 
+            election.candidate_string.upcase ]
+  return true 
+end
+
+## samller helper functions
+#############################
+
+def trim_body(body="")
+  body.strip!
+  body.sub!(/^(.*?)(-- |\n\n).*$/m, '\1')
+  body.gsub!(/\s+/, ' ')
+  body.strip!
+  return body
+end
+
+# this message takes a message and sends it back to the person who is
+# calling the script
+def reply(msg=nil)
+  # create a new blank message
+  outgoing = RMail::Message.new
+  header = outgoing.header
+  
+  # fill the values in
+  header.to = @email
+  header.from = @from
+  header.subject = "vote"
+  outgoing.body = msg
+
+  # send the mail over SMTP
+  Net::SMTP.start('localhost', 25) do |smtp|
+    smtp.send_message outgoing.to_s, @from, @email
+  end
+end
+
+def build_options(args=[])
+  options = Hash.new
+  candidates = Hash.new
+  
+  args.each do |item|
+    if item =~ /\w+:\w+/
+      key, value = *item.split(':')
+      if key.length == 1
+        candidates[key] = value
+      else
+        options[key] = value
+      end
+    end
+  end
+
+  if candidates.length > 0
+    options['candidates'] = candidates
+  end
+
+  options
+end
+
+
+## main logic of the program
+#######################################################
+
+
+# run this each time to turn off any old emails
+cleanup()
+
+# grab the command out of the email 
+incoming_msg = RMail::Parser.read(STDIN)
+
+# record the address from the incoming mail
+@email =  incoming_msg.header.from.first.address
+
+## try to extract the body of the mail
+# first test to see if its multipart
+if incoming_msg.multipart?
+  incoming_msg.body.each do |msg|
+    if msg.header.media_type == 'text' and msg.header.subtype == 'plain'
+      command = trim_body(incoming_msg.body)
+      break
+    end
+  end
+
+# if it's not multipart, just grab the contents
+else
+  command = trim_body(incoming_msg.body)
+end
+
+# parse the commands command
+args = command.split.collect {|s| s.downcase}
+
+# if the person is creating a new vote
+if args[0] == 'new' or args[0] == 'create'
+
+  # verify that we have been given the right number of arguments
+  if args[1] and args[2]
+
+    # attempt to create the new election
+    new_election(args[1], args[2], build_options(args[3..-1]))
+
+  else
+    # otherwise, provide feedback
+    reply "Command be of the form: \"new votename ABCD\" where ABCD is the list of candidates."
+  end
+
+# if the person is asking for help
+elsif args[0] == "help" or args[0] == '?'
+  reply "Commands: \"new yourvotename ABCD\"; \"yourvotename BDCA\" (in preferred order); \"res yourvotename\", "
+
+# if the person is trying to get results
+elsif args[0] =~ /^res/
+  results(args[1])
+
+elsif args[0] == 'info'
+  provide_info(args[1])
+  
+elsif Election.find_all(["active = 1 and name = ?", args[0]]).length == 1 \
+      and not args[1]
+  provide_info(args[0])
+
+# otherwise, check to see if it's a vote prefixed by a votename
+elsif Election.find_all(["active = 1 and name = ?", args[0]]).length == 1
+  register_vote args[0], args[1]
+
+# otherwise, we don't know, give an error
+else
+  reply "I don't understand your command. Perhaps you have the wrong " +
+        "vote name? Reply \"help\" for more information."
+end
+

Benjamin Mako Hill || Want to submit a patch?