congress_forms 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
data/README.md ADDED
@@ -0,0 +1,152 @@
1
+ # CongressForms
2
+
3
+
4
+ ## Installation
5
+
6
+ Add this line to your application's Gemfile:
7
+
8
+ ```ruby
9
+ gem 'congress_forms'
10
+ ```
11
+
12
+ And then execute:
13
+
14
+ $ bundle
15
+
16
+ Or install it yourself as:
17
+
18
+ $ gem install congress_forms
19
+
20
+
21
+ ### Program Dependencies
22
+
23
+ * google-chrome, git
24
+
25
+
26
+ ## Usage
27
+
28
+ To send a message to Congress, begin by creating a form object. Senators should be identified by their [BioGuide ID](https://www.congress.gov/help/field-values/member-bioguide-ids), while Representatives are identified by an office code H*XXYY*, where XX is their state and YY is their district.
29
+
30
+ ```ruby
31
+ # Form for Senator Kamala Harris (BioGuide H001075)
32
+ CongressForms::Form.find("H001075")
33
+
34
+ # Form for CA-13 Represenative Barbara Lee (office code HCA13)
35
+ CongressForms::Form.find("HCA13")
36
+ ```
37
+
38
+ Each Senator's office may require different fields, provide different options for select menus, etc. You can query the fields required by a particular form by calling `CongressForms::Form#required_params`.
39
+
40
+ ```ruby
41
+ # List required message parameters
42
+ irb(main)> CongressForms::Form.find("H001075").required_params
43
+ [
44
+ # Required text fields
45
+ { :value => "$NAME_FIRST", :max_length => nil },
46
+ { :value => "$NAME_LAST", :max_length => nil },
47
+
48
+ # Required multiple choice field
49
+ { :value => "$NAME_PREFIX", :options => ["Mr.", "Ms.", "Mrs.", ...] },
50
+
51
+ # Required multiple choice field with distinct labels and values
52
+ {
53
+ :value => "$TOPIC", :options => {
54
+ "Abortion" => "943AD4D7-5056-A066-60A5-D652A671D70E",
55
+ "Agriculture" => "943AD58A-5056-A066-60BD-A9DBEE1187A1",
56
+ "Animal Welfare" => "943AD622-5056-A066-6065-1B45E2F6F45D",
57
+ }
58
+ },
59
+ ...
60
+ ]
61
+ ...
62
+ ```
63
+
64
+ Pass the required values, in a hash, to `CongressForms::Form#fill` to send the message.
65
+
66
+ ```ruby
67
+ form = CongressForms::Form.find("H001075")
68
+
69
+ form.fill(
70
+ "$NAME_FIRST" => "...",
71
+ "$NAME_LAST" => "...",
72
+ "$MESSAGE" => "...",
73
+ ...
74
+ )
75
+ ```
76
+
77
+ For Senate offices, this will fill out the representative's contact form with a headless instance of Google Chrome. For House offices, messages are submitted through the Communicating with Congress (CWC) API.
78
+
79
+
80
+ ### CLI Usage
81
+
82
+ You can also send messages from the command line:
83
+
84
+ ```
85
+ $ bin/congress_forms --help
86
+ Usage: congress_forms [options]
87
+ -i, --rep_id REP_ID ID of the representative to message
88
+ -r, --repo DIR Location for unitedstates/contact_congress repository
89
+ -p, --param KEY=VALUE e.g. -p NAME_FIRST=Badger
90
+ ```
91
+
92
+
93
+ ## Operation and Configuration
94
+
95
+ Senate messages rely on contact form details tracked by the [unitedstates/contact-congress](https://github.com/unitedstates/contact-congress) project. This repo is cloned into a temporary directory by default. You can configure CongressForms to use an existing/persistent direcory with
96
+
97
+ ```ruby
98
+ CongressForms.contact_congress_repository = "data/contact_congress"
99
+ ```
100
+
101
+ A `git pull` is performed every now and then in this direcory, to keep the form details up to date. You can disable this behavior with
102
+
103
+ ```ruby
104
+ CongressForms.auto_update_contact_congress = false
105
+ ```
106
+
107
+ House messages are submitted through the [Communicating with Congress](https://www.house.gov/doing-business-with-the-house/communicating-with-congress-cwc) API. You will need to complete the vendor application process, then configure the API client with
108
+
109
+ ```ruby
110
+ Cwc::Client.configure(
111
+ api_key: ENV["CWC_API_KEY"],
112
+ host: ENV["CWC_HOST"],
113
+ delivery_agent: ENV["CWC_DELIVERY_AGENT"],
114
+ delivery_agent_ack_email: ENV["CWC_DELIVERY_AGENT_ACK_EMAIL"],
115
+ delivery_agent_contact_name: ENV["CWC_DELIVERY_AGENT_CONTACT_NAME"],
116
+ delivery_agent_contact_email: ENV["CWC_DELIVERY_AGENT_CONTACT_EMAIL"],
117
+ delivery_agent_contact_phone: ENV["CWC_DELIVERY_AGENT_CONTACT_PHONE"]
118
+ )
119
+ ```
120
+
121
+ ### CWC Concerns
122
+
123
+ The CWC API requires that you connect from a whitelisted IP address. This is true even for the test endpoint, which makes development and testing of the API client tricky.
124
+
125
+ If you have a whitelisted IP, you can use SSH port forwarding to tunnel requests to CWC through the approved server. Keep this command running in a console:
126
+
127
+ ```
128
+ $ ssh -L [port]:test-cwc.house.gov:443 [server]
129
+ ```
130
+
131
+ Use `https://localhost:[port]/` as your CWC host, and define these environment variables:
132
+
133
+ ```
134
+ CWC_VERIFY_SSL=false
135
+ CWC_HOST_HEADER=test-cwc.house.gov
136
+ ```
137
+
138
+ (substitute `[server]` and `[port]` with your own values)
139
+
140
+ ### Disabling headless mode
141
+
142
+ Chrome can be run in windowed mode by setting the environment variable `HEADLESS=0`.
143
+
144
+ ## Contributing
145
+
146
+ Bug reports and pull requests are welcome on GitHub at https://github.com/efforg/congress_forms.
147
+
148
+
149
+ ## License
150
+
151
+ The gem is available as open source under the terms of the [GPLv3 License](https://github.com/EFForg/congress_forms/blob/master/LICENSE.txt).
152
+
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task :default => :spec
@@ -0,0 +1,100 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "optparse"
4
+
5
+ require "bundler/setup"
6
+ require "congress_forms"
7
+
8
+ require "dotenv/load"
9
+
10
+ unless ENV["CWC_API_KEY"].nil?
11
+ Cwc::Client.configure(
12
+ api_key: ENV["CWC_API_KEY"],
13
+ host: ENV["CWC_HOST"],
14
+ delivery_agent: ENV["CWC_DELIVERY_AGENT"],
15
+ delivery_agent_ack_email: ENV["CWC_DELIVERY_AGENT_ACK_EMAIL"],
16
+ delivery_agent_contact_name: ENV["CWC_DELIVERY_AGENT_CONTACT_NAME"],
17
+ delivery_agent_contact_email: ENV["CWC_DELIVERY_AGENT_CONTACT_EMAIL"],
18
+ delivery_agent_contact_phone: ENV["CWC_DELIVERY_AGENT_CONTACT_PHONE"]
19
+ )
20
+ end
21
+
22
+ options, params = { submit: true }, {}
23
+
24
+ ENV.each do |k, v|
25
+ next unless m = k.match(/CONGRESS_FORMS_(.+)/)
26
+ params["$#{m[1]}"] = v
27
+ end
28
+
29
+ opts = OptionParser.new do |opts|
30
+ opts.on("--rep_id REP_ID", "-i", "ID of the representative to message") do |id|
31
+ options[:rep_id] = id
32
+ end
33
+
34
+ opts.on("--repo DIR", "-r", "Location for unitedstates/contact_congress repository") do |dir|
35
+ CongressForms.contact_congress_repository = dir
36
+ end
37
+
38
+ opts.on("--param KEY=VALUE", "-p", "e.g. -p NAME_FIRST=Badger") do |pair|
39
+ key, value = pair.split("=", 2)
40
+ params["$#{key}"] = value
41
+ end
42
+
43
+ opts.on("--no-submit", "Fill out the form without submitting") do
44
+ options[:submit] = false
45
+ end
46
+
47
+ opts.on("--debug", "Debug mode (HEADLESS=0, binding.pry if an exception is raised)") do
48
+ ENV["HEADLESS"] = "0"
49
+ options[:debug] = true
50
+ end
51
+ end.tap(&:parse!)
52
+
53
+ unless options[:rep_id]
54
+ warn opts.help
55
+ exit(1)
56
+ end
57
+
58
+ form = CongressForms::Form.find(options[:rep_id])
59
+
60
+ form.required_params.each do |param|
61
+ next if params[param[:value]]
62
+
63
+ print("#{param[:value]}: ")
64
+
65
+ if param[:options]
66
+ choices =
67
+ if param[:options].is_a?(Array)
68
+ param[:options].zip(param[:options]).to_h
69
+ else
70
+ param[:options]
71
+ end
72
+
73
+ puts
74
+ choices.each_with_index do |choice, i|
75
+ puts("#{i+1}. #{choice[0]}")
76
+ end
77
+
78
+ puts
79
+ print("Choice: ")
80
+ i = $stdin.gets or exit(1)
81
+
82
+ choice = choices.to_a[i.to_i-1][0]
83
+ params[param[:value]] = choices[choice]
84
+ puts
85
+ else
86
+ params[param[:value]] = $stdin.gets or exit(1)
87
+ params[param[:value]].chomp!
88
+ end
89
+ end
90
+
91
+ begin
92
+ form.fill(params, submit: options[:submit])
93
+ rescue Exception => e
94
+ if options[:debug]
95
+ require "pry"
96
+ binding.pry
97
+ else
98
+ raise e
99
+ end
100
+ end
data/bin/console ADDED
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "dotenv/load"
5
+
6
+ require "congress_forms"
7
+
8
+ require "irb"
9
+ IRB.start
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,30 @@
1
+ lib = File.expand_path('../lib', __FILE__)
2
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
3
+ require 'congress_forms/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "congress_forms"
7
+ spec.version = CongressForms::VERSION
8
+ spec.authors = ["Peter Woo"]
9
+ spec.email = ["peterw@eff.org"]
10
+
11
+ spec.summary = %q{...}
12
+ spec.homepage = "https://github.com/efforg/congress_forms"
13
+ spec.license = "GPL-3.0"
14
+
15
+ spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
16
+ spec.bindir = "bin"
17
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
18
+ spec.require_paths = ["lib", "cwc/lib"]
19
+
20
+ spec.add_development_dependency "pry", "~> 0.11"
21
+ spec.add_development_dependency "bundler", "~> 1.12"
22
+ spec.add_development_dependency "rake", "~> 10.0"
23
+ spec.add_development_dependency "rspec", "~> 3.0"
24
+ spec.add_development_dependency "dotenv", "~> 2.5"
25
+
26
+ spec.add_dependency "capybara-selenium", "~> 0.0.6"
27
+ spec.add_dependency "chromedriver-helper", "~> 1.2"
28
+ spec.add_dependency "nokogiri", ">= 1.8.2"
29
+ spec.add_dependency "rest-client", "~> 2.0"
30
+ end
@@ -0,0 +1,160 @@
1
+ module CongressForms
2
+ module Actions
3
+ DEFAULT_FIND_WAIT_TIME = 5
4
+
5
+ def self.build(step)
6
+ key = step.keys.first
7
+
8
+ const_name = key.capitalize.gsub(/_(\w)/){ |m| m[1].upcase }
9
+ klass = const_get(const_name, false)
10
+
11
+ if Visit == klass
12
+ Array(klass.new("value" => step[key]))
13
+ else
14
+ step[key].map do |params|
15
+ klass.new(params)
16
+ end
17
+ end
18
+ end
19
+
20
+ class Base
21
+ attr_accessor :selector, :value, :options, :required
22
+ alias :required? :required
23
+
24
+ def initialize(params = {})
25
+ self.selector = params["selector"]
26
+ self.value = params["value"]
27
+ self.options = params["options"] || {}
28
+ self.required = !!params["required"]
29
+ end
30
+
31
+ def max_length
32
+ options.is_a?(Hash) ? options["max_length"] : nil
33
+ end
34
+
35
+ def select_options
36
+ [Choose, Select].include?(self.class) ? options : nil
37
+ end
38
+
39
+ def placeholder_value?
40
+ value[0, 1] == "$"
41
+ end
42
+
43
+ def escape_css_attribute(v)
44
+ v.gsub('"', '\"')
45
+ end
46
+
47
+ def submit?
48
+ "#{value} #{selector}".match(/submit/i)
49
+ end
50
+
51
+ def inspect
52
+ s = "#{self.class.name.sub(/^CongressForms::Actions::/, '')}("
53
+ s << "#{selector.inspect}, " unless selector.nil?
54
+ s << value.inspect << ")"
55
+ end
56
+ end
57
+
58
+ class Visit < Base
59
+ def perform(browser, params={})
60
+ browser.visit(value)
61
+ end
62
+ end
63
+
64
+ class Wait < Base
65
+ def perform(browser, params={})
66
+ sleep(value.to_i)
67
+ end
68
+ end
69
+
70
+ class FillIn < Base
71
+ def perform(browser, params={})
72
+ if placeholder_value?
73
+ value = params.fetch(self.value).gsub("\t", " ")
74
+
75
+ maxl = options["max_length"]
76
+ value = value[0, (0.95 * maxl).floor] if maxl
77
+ else
78
+ value = self.value
79
+ end
80
+
81
+ browser.find(selector).set(value)
82
+ end
83
+ end
84
+
85
+ class Select < Base
86
+ def perform(browser, params={})
87
+ user_value = params[value]
88
+
89
+ browser.within(selector) do
90
+ if placeholder_value?
91
+ option_value = user_value
92
+ else
93
+ option_value = value
94
+ end
95
+
96
+ begin
97
+ elem = browser.first('option[value="' + escape_css_attribute(option_value) + '"]')
98
+ rescue Capybara::ElementNotFound
99
+ elem = browser.first('option', text: Regexp.compile("^" + Regexp.escape(option_value) + "(\\W|$)"))
100
+ end
101
+
102
+ elem.select_option
103
+ end
104
+ rescue Capybara::ElementNotFound => e
105
+ raise e, e.message unless options == "DEPENDENT"
106
+ end
107
+ end
108
+
109
+ class ClickOn < Base
110
+ def perform(browser, params={})
111
+ browser.find(selector).click
112
+ end
113
+ end
114
+
115
+ class Find < Base
116
+ def perform(browser, params={})
117
+ wait_val = options["wait"] || DEFAULT_FIND_WAIT_TIME
118
+
119
+ if value.nil?
120
+ browser.find(selector, wait: wait_val)
121
+ else
122
+ browser.find(selector, text: Regexp.compile(value),
123
+ wait: wait_val)
124
+ end
125
+ end
126
+ end
127
+
128
+ class Check < Base
129
+ def perform(browser, params={})
130
+ browser.find(selector).set(true)
131
+ end
132
+ end
133
+
134
+ class Uncheck < Base
135
+ def perform(browser, params={})
136
+ browser.find(selector).set(false)
137
+ end
138
+ end
139
+
140
+ class Choose < Base
141
+ def perform(browser, params={})
142
+ if options.any?
143
+ user_value = params[value]
144
+
145
+ browser.
146
+ find(selector + '[value="' + escape_css_attribute(user_value) + '"]').
147
+ set(true)
148
+ else
149
+ browser.find(selector).set(true)
150
+ end
151
+ end
152
+ end
153
+
154
+ class Javascript < Base
155
+ def perform(browser, params={})
156
+ browser.driver.evaluate_script(value)
157
+ end
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,62 @@
1
+ module CongressForms
2
+ class CwcForm < Form
3
+ attr_accessor :office_code
4
+
5
+ def initialize(office_code)
6
+ self.office_code = office_code
7
+ end
8
+
9
+ def updated_at
10
+ Time.at(0)
11
+ end
12
+
13
+ def required_params
14
+ Cwc::RequiredJson.fetch("required_actions").map(&:dup)
15
+ end
16
+
17
+ def fill(values, campaign_tag: nil, organization: nil, browser: nil, validate_only: false)
18
+ params = {
19
+ campaign_id: campaign_tag || SecureRandom.hex(16),
20
+
21
+ recipient: { member_office: office_code },
22
+
23
+ constituent: {
24
+ prefix: values["$NAME_PREFIX"],
25
+ first_name: values["$NAME_FIRST"],
26
+ last_name: values["$NAME_LAST"],
27
+ address: Array(values["$ADDRESS_STREET"]),
28
+ city: values["$ADDRESS_CITY"],
29
+ state_abbreviation: values["$ADDRESS_STATE_POSTAL_ABBREV"],
30
+ zip: values["$ADDRESS_ZIP5"],
31
+ email: values["$EMAIL"]
32
+ },
33
+
34
+ message: {
35
+ subject: values["$SUBJECT"],
36
+ library_of_congress_topics: Array(values["$TOPIC"])
37
+ }
38
+ }
39
+
40
+ if organization
41
+ params[:organization] = organization
42
+ end
43
+
44
+ if values["$STATEMENT"]
45
+ params[:message][:organization_statement] = values["$STATEMENT"]
46
+ end
47
+
48
+ if values["$MESSAGE"] && values["$MESSAGE"] != values["$STATEMENT"]
49
+ params[:message][:constituent_message] = values["$MESSAGE"]
50
+ end
51
+
52
+ cwc_client = Cwc::Client.new
53
+ message = cwc_client.create_message(params)
54
+
55
+ if validate_only
56
+ cwc_client.validate(message)
57
+ else
58
+ cwc_client.deliver(message)
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,36 @@
1
+ module CongressForms
2
+ class Form
3
+ @@repo = nil
4
+
5
+ def self.repo
6
+ @@repo ||=
7
+ Repo.new(CongressForms.contact_congress_remote).tap do |repo|
8
+ repo.location = CongressForms.contact_congress_repository
9
+ repo.auto_update = CongressForms.auto_update_contact_congress?
10
+ end
11
+ end
12
+
13
+ def self.find(form_id)
14
+ if Cwc::Client.new.office_supported?(form_id)
15
+ CwcForm.new(form_id)
16
+ else
17
+ content, timestamp = repo.find("members/#{form_id}.yaml")
18
+ WebForm.parse(content, updated_at: timestamp)
19
+ end
20
+ rescue Errno::ENOENT => e
21
+ nil
22
+ end
23
+
24
+ def missing_required_params(params)
25
+ missing_parameters = []
26
+
27
+ required_params.each do |field|
28
+ unless params.include?(field[:value])
29
+ missing_parameters << field[:value]
30
+ end
31
+ end
32
+
33
+ missing_parameters.empty? ? nil : missing_parameters.any
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,95 @@
1
+ require "tmpdir"
2
+ require "fileutils"
3
+
4
+ module CongressForms
5
+ class Repo
6
+ attr_reader :remote
7
+ attr_accessor :auto_update
8
+
9
+ alias :auto_update? :auto_update
10
+
11
+ def initialize(remote)
12
+ @remote = remote
13
+ @semaphore = Mutex.new
14
+ self.auto_update = true
15
+ end
16
+
17
+ def location
18
+ @location ||= Pathname.new(Dir.mktmpdir).tap do |tmpdir|
19
+ Kernel.at_exit{ FileUtils.rm_r(tmpdir) }
20
+ end
21
+ end
22
+
23
+ def location=(loc)
24
+ @location = loc ? Pathname.new(loc) : nil
25
+ end
26
+
27
+ def clone
28
+ system(
29
+ "git",
30
+ "clone",
31
+ "--quiet",
32
+ "--depth", "1",
33
+ remote,
34
+ location.to_s
35
+ ) or raise Error, "Error cloning repo at #{remote}"
36
+ end
37
+
38
+ def initialized?
39
+ File.exists?(git_dir)
40
+ end
41
+
42
+ def update!
43
+ system(
44
+ "git",
45
+ "--git-dir", git_dir.to_s,
46
+ "pull",
47
+ "--quiet",
48
+ "--ff-only"
49
+ ) or raise Error, "Error updating git repo at #{location}"
50
+ end
51
+
52
+ def update
53
+ begin
54
+ update!
55
+ rescue
56
+ end
57
+ end
58
+
59
+ def age
60
+ repo_touched_at = File.mtime(git_dir.join("HEAD"))
61
+ Time.now - repo_touched_at
62
+ end
63
+
64
+ def find(file)
65
+ lock do
66
+ clone unless initialized?
67
+
68
+ update if auto_update? && age > 5*60 # update every 5m
69
+
70
+ repo_file = system(
71
+ "git",
72
+ "--git-dir", git_dir.to_s,
73
+ "ls-files", "--error-unmatch",
74
+ "--", file
75
+ )
76
+
77
+ raise Errno::ENOENT, file unless repo_file
78
+
79
+ path = location.join(file).to_s
80
+
81
+ [File.read(path), File.mtime(path)]
82
+ end
83
+ end
84
+
85
+ def git_dir
86
+ location.join(".git")
87
+ end
88
+
89
+ private
90
+
91
+ def lock(&block)
92
+ @semaphore.synchronize(&block)
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,3 @@
1
+ module CongressForms
2
+ VERSION = "0.1.1"
3
+ end