congress_forms 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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