+#!/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
+