From 4e89e6ec1729bb02239b3f6dd5d388ac0c5c9073 Mon Sep 17 00:00:00 2001 From: Benjamin Mako Hill Date: Mon, 24 May 2010 17:39:59 -0400 Subject: [PATCH] add votemail --- misc/votemail | 347 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 347 insertions(+) create mode 100755 misc/votemail diff --git a/misc/votemail b/misc/votemail new file mode 100755 index 0000000..d928126 --- /dev/null +++ b/misc/votemail @@ -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 " # 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 + -- 2.30.2