add votemail
[selectricity] / misc / votemail
1 #!/usr/bin/env ruby
2
3 ## bring in the rubyvote library
4 require '/home/mako/bin/lib/rubyvote'
5
6 # list of support election methods
7 @voting_methods = { 'plurality' => PluralityVote,
8                     'approval' => ApprovalVote,
9                     'condorcet' => CondorcetVote,
10                     'ssd' => CloneproofSSDVote,
11                     'borda' => BordaVote,
12                     'runoff' => InstantRunoffVote }
13
14 @default_method = 'ssd'
15
16 # ruby mail handling
17 require 'rmail'
18 require 'net/smtp'
19 @from = "Selectricity Anywhere <vote@selectricity.org>" # from address
20
21 ## create an active record connection to an SQLite3 database where we
22 ## will store all of the vote data 
23 require 'sqlite3'
24 require '/usr/share/rails/activerecord/lib/active_record.rb'
25
26 ActiveRecord::Base.establish_connection(
27   :adapter=> 'sqlite3',
28   :dbfile=> '/home/mako/.votemail.db')
29
30 class Election < ActiveRecord::Base
31   has_many :votes
32 end
33
34 class Vote < ActiveRecord::Base
35   belongs_to :election
36 end
37
38 # expire old elections
39 def cleanup 
40   Election.find_all(["active = 1"]).each do |election|
41     if Time.now - election.startdate > 1.hour
42       election.active = 0
43       election.save
44     end
45   end
46 end
47
48 # create a new election object
49 def new_election(name, candidate_string=nil, options={})
50
51   # check to see if the name is in use
52   if Election.find_all(["active = 1 and name = ?", name]).length > 0 
53     reply "Vote name \"%s\" in use. Try later or with new name." % name
54     return false
55   end
56
57   # check to see if it is a verboten name
58   if name == 'new' or name == 'create' or name =~ /^res/
59     reply "Sorry. You can't create a vote with that name."
60     return false
61   end
62
63   # see if there are any repeat candidates
64   if candidate_string.split(//).any? {|i| candidate_string.split(//).grep(i).length > 1}
65     reply "The choice list cannot contain repeated characters." 
66     return false
67   end
68  
69   # make sure there is a candidate list
70   if candidate_string.split(//).length < 2
71     reply "A vote needs at least two candidates"
72     return false
73   end
74
75   # otherwise, we have what we need to create and populate the new
76   # election
77   election = Election.new
78   election.name = name
79   election.candidate_string = candidate_string.split(//).sort.join("")
80   election.startdate = Time.now
81   election.active = 1
82
83   # set the voting method
84   if options.has_key?('method')
85     if @voting_methods.has_key?(options['method'])
86       election.voting_method = options['method']
87     else
88       reply "Unsupported voting method. Vote not created."
89       return false
90     end
91   else
92     election.voting_method = @default_method
93   end 
94
95   # save the info string on candidates if anything is provided
96   if options.has_key?('candidates')
97      
98     # generate the info string
99     election.info = candidate_string.split(//).collect do |c|
100       if options['candidates'].has_key?(c)
101         description = options['candidates'][c]
102       else
103         description = 'unknown'
104       end
105       [c.upcase, description].join(":")
106     end.join(", ")
107   end
108
109   if election.save
110     reply ("Vote created. Vote like: \"%s %s\". " +
111            "Arrange capital letters in order of voter preference. " +
112            "Send \"res %s\" for results.") % \
113           [name, candidate_string.upcase, name]
114     return true
115   else
116     reply "Failure creating vote."
117     return false
118   end
119 end
120
121 def register_vote(name=nil, votestring=nil)
122
123   election = Election.find_all(["active = 1 and name = ?", name])[0]
124
125   # check that the vote string doesn't contain duplicates
126   if votestring.split(//).any? {|i| votestring.split(//).grep(i).length > 1}
127     reply "The choice list cannot contain repeated characters."
128     return false
129
130   # OR extras 
131   elsif votestring.split(//).any? {|i| not election.candidate_string.split(//).include?(i)}
132     reply "The choice list must only include these characters: %s" % election.candidate_string
133     return false
134   end
135   
136   unless election.voting_method == 'plurality' or election.voting_method == 'approval'
137   # OR doesn't list all of the necessary options
138     if votestring.length != election.candidate_string.length
139       reply "You must list ALL of these choices: \"%s\"" % election.candidate_string
140       return false
141     end
142   end
143
144   # if this person has voted already, blow away the vote
145   Vote.find_all(["election_id = ? and email = ?", election.id, @email]).each do |vote|
146     vote.destroy
147   end
148
149   # create the new vote and populate the string
150   vote = Vote.new
151   vote.votestring = votestring
152   vote.email = @email
153   vote.election = election
154
155   # attach the vote to the election
156   if vote.save
157     reply "Vote recorded successfully as: %s" % vote.votestring.upcase
158     return true
159   else
160     reply "Failed to register your vote."
161     return false
162   end 
163 end
164
165 def results(name=nil)
166
167   # ensure that we can find a vote by the name
168   if Election.find_all(["active = 1 and name = ?", name]).length != 1
169     reply "Sorry! I cannot find active vote by that name."
170     return false
171   else
172     election = Election.find_all(["active = 1 and name = ?", name])[0]
173   end
174
175   if election.votes.length == 0
176     reply "I have recorded no votes for that election."
177     return false
178   end
179   
180   # build the tally of the number of votes
181   tally = Array.new
182   election.votes.each do |vote|
183     tally <<  vote.votestring.split(//)
184   end
185
186   result = @voting_methods[election.voting_method].new(tally).result
187   
188   if result.winner?
189     if result.winners.length == 1
190       message = "The winner is: %s" % result.winners[0].upcase
191     else 
192       message = "The winners are: %s" % result.winners.join(", ")
193     end
194   else
195     message = "There was no winner."
196   end
197   
198   message += (" (Total votes: %s)" % election.votes.length )
199   reply(message)
200   return true
201 end
202
203 def provide_info(name=nil)
204   if not name or Election.find_all(["active = 1 and name = ?", name]).length != 1
205     reply "Sorry! I cannot find an active vote by that name."
206     return false
207   else
208     election = Election.find_all(["active = 1 and name = ?", name])[0]
209   end
210
211   if election.info
212     info_string = election.info
213   else
214     info_string = election.candidate_string.upcase.split(//).join(", ")
215   end
216   
217   reply ("Choices include %s. Please list ALL and reply with " +
218          "\"%s %s\" with %s being in the order of your preference.") \
219          % [ info_string, election.name,
220              election.candidate_string.upcase, 
221              election.candidate_string.upcase ]
222   return true 
223 end
224
225 ## samller helper functions
226 #############################
227
228 def trim_body(body="")
229   body.strip!
230   body.sub!(/^(.*?)(-- |\n\n).*$/m, '\1')
231   body.gsub!(/\s+/, ' ')
232   body.strip!
233   return body
234 end
235
236 # this message takes a message and sends it back to the person who is
237 # calling the script
238 def reply(msg=nil)
239   # create a new blank message
240   outgoing = RMail::Message.new
241   header = outgoing.header
242   
243   # fill the values in
244   header.to = @email
245   header.from = @from
246   header.subject = "vote"
247   outgoing.body = msg
248
249   # send the mail over SMTP
250   Net::SMTP.start('localhost', 25) do |smtp|
251     smtp.send_message outgoing.to_s, @from, @email
252   end
253 end
254
255 def build_options(args=[])
256   options = Hash.new
257   candidates = Hash.new
258   
259   args.each do |item|
260     if item =~ /\w+:\w+/
261       key, value = *item.split(':')
262       if key.length == 1
263         candidates[key] = value
264       else
265         options[key] = value
266       end
267     end
268   end
269
270   if candidates.length > 0
271     options['candidates'] = candidates
272   end
273
274   options
275 end
276
277
278 ## main logic of the program
279 #######################################################
280
281
282 # run this each time to turn off any old emails
283 cleanup()
284
285 # grab the command out of the email 
286 incoming_msg = RMail::Parser.read(STDIN)
287
288 # record the address from the incoming mail
289 @email =  incoming_msg.header.from.first.address
290
291 ## try to extract the body of the mail
292 # first test to see if its multipart
293 if incoming_msg.multipart?
294   incoming_msg.body.each do |msg|
295     if msg.header.media_type == 'text' and msg.header.subtype == 'plain'
296       command = trim_body(incoming_msg.body)
297       break
298     end
299   end
300
301 # if it's not multipart, just grab the contents
302 else
303   command = trim_body(incoming_msg.body)
304 end
305
306 # parse the commands command
307 args = command.split.collect {|s| s.downcase}
308
309 # if the person is creating a new vote
310 if args[0] == 'new' or args[0] == 'create'
311
312   # verify that we have been given the right number of arguments
313   if args[1] and args[2]
314
315     # attempt to create the new election
316     new_election(args[1], args[2], build_options(args[3..-1]))
317
318   else
319     # otherwise, provide feedback
320     reply "Command be of the form: \"new votename ABCD\" where ABCD is the list of candidates."
321   end
322
323 # if the person is asking for help
324 elsif args[0] == "help" or args[0] == '?'
325   reply "Commands: \"new yourvotename ABCD\"; \"yourvotename BDCA\" (in preferred order); \"res yourvotename\", "
326
327 # if the person is trying to get results
328 elsif args[0] =~ /^res/
329   results(args[1])
330
331 elsif args[0] == 'info'
332   provide_info(args[1])
333   
334 elsif Election.find_all(["active = 1 and name = ?", args[0]]).length == 1 \
335       and not args[1]
336   provide_info(args[0])
337
338 # otherwise, check to see if it's a vote prefixed by a votename
339 elsif Election.find_all(["active = 1 and name = ?", args[0]]).length == 1
340   register_vote args[0], args[1]
341
342 # otherwise, we don't know, give an error
343 else
344   reply "I don't understand your command. Perhaps you have the wrong " +
345         "vote name? Reply \"help\" for more information."
346 end
347

Benjamin Mako Hill || Want to submit a patch?