followupboss_client 1.0.0
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.
- checksums.yaml +7 -0
- data/.env.example +12 -0
- data/.gitignore +18 -0
- data/.rspec +2 -0
- data/.ruby-gemset +1 -0
- data/.ruby-version +1 -0
- data/.tool-versions +1 -0
- data/.travis.yml +9 -0
- data/CHANGELOG.md +149 -0
- data/CODE_OF_CONDUCT.md +49 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +22 -0
- data/README.md +882 -0
- data/Rakefile +6 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/followupboss_client.gemspec +43 -0
- data/lib/followupboss_client.rb +16 -0
- data/lib/fub_client/action_plan.rb +5 -0
- data/lib/fub_client/appointment.rb +42 -0
- data/lib/fub_client/appointment_outcome.rb +51 -0
- data/lib/fub_client/appointment_type.rb +51 -0
- data/lib/fub_client/call.rb +4 -0
- data/lib/fub_client/client.rb +200 -0
- data/lib/fub_client/compatibility.rb +18 -0
- data/lib/fub_client/configuration.rb +54 -0
- data/lib/fub_client/cookie_client.rb +190 -0
- data/lib/fub_client/custom_field.rb +5 -0
- data/lib/fub_client/deal.rb +41 -0
- data/lib/fub_client/deal_attachment.rb +61 -0
- data/lib/fub_client/deal_custom_field.rb +47 -0
- data/lib/fub_client/em_event.rb +5 -0
- data/lib/fub_client/email_template.rb +5 -0
- data/lib/fub_client/event.rb +8 -0
- data/lib/fub_client/group.rb +58 -0
- data/lib/fub_client/her_patch.rb +101 -0
- data/lib/fub_client/identity.rb +33 -0
- data/lib/fub_client/message.rb +41 -0
- data/lib/fub_client/middleware/authentication.rb +26 -0
- data/lib/fub_client/middleware/cookie_authentication.rb +61 -0
- data/lib/fub_client/middleware/parser.rb +59 -0
- data/lib/fub_client/middleware.rb +8 -0
- data/lib/fub_client/note.rb +4 -0
- data/lib/fub_client/people_relationship.rb +34 -0
- data/lib/fub_client/person.rb +5 -0
- data/lib/fub_client/person_attachment.rb +50 -0
- data/lib/fub_client/pipeline.rb +45 -0
- data/lib/fub_client/property.rb +26 -0
- data/lib/fub_client/rails8_patch.rb +39 -0
- data/lib/fub_client/resource.rb +33 -0
- data/lib/fub_client/shared_inbox.rb +389 -0
- data/lib/fub_client/smart_list.rb +5 -0
- data/lib/fub_client/stage.rb +39 -0
- data/lib/fub_client/task.rb +18 -0
- data/lib/fub_client/team.rb +65 -0
- data/lib/fub_client/team_inbox.rb +65 -0
- data/lib/fub_client/text_message.rb +46 -0
- data/lib/fub_client/text_message_template.rb +49 -0
- data/lib/fub_client/user.rb +4 -0
- data/lib/fub_client/version.rb +3 -0
- data/lib/fub_client/webhook.rb +47 -0
- data/lib/fub_client.rb +61 -0
- data/scripts/test_api.rb +110 -0
- data/scripts/test_shared_inbox.rb +90 -0
- metadata +335 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
module FubClient
|
|
2
|
+
class CookieClient
|
|
3
|
+
attr_accessor :gist_url, :encryption_key, :subdomain
|
|
4
|
+
attr_reader :cookies
|
|
5
|
+
|
|
6
|
+
def initialize(subdomain: nil, gist_url: nil, encryption_key: nil, cookie: nil)
|
|
7
|
+
config = FubClient.configuration
|
|
8
|
+
|
|
9
|
+
@subdomain = subdomain || config.subdomain
|
|
10
|
+
@gist_url = gist_url || config.gist_url
|
|
11
|
+
@encryption_key = encryption_key || config.encryption_key
|
|
12
|
+
|
|
13
|
+
raise ArgumentError, 'Subdomain is required for cookie authentication' unless @subdomain
|
|
14
|
+
|
|
15
|
+
if cookie
|
|
16
|
+
self.cookies = cookie
|
|
17
|
+
puts "Using provided cookie (#{cookie.length} chars)" if ENV['DEBUG']
|
|
18
|
+
elsif @gist_url && @encryption_key
|
|
19
|
+
raise ArgumentError, 'Failed to fetch or decrypt cookie from GIST URL' unless fetch_cookie_from_gist
|
|
20
|
+
else
|
|
21
|
+
raise ArgumentError, "Either 'cookie' or both 'gist_url' and 'encryption_key' must be provided"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
configure_client
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def cookies=(value)
|
|
28
|
+
@cookies = value
|
|
29
|
+
client.cookies = value if @cookies
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def client
|
|
33
|
+
@client ||= FubClient::Client.instance
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def configure_client
|
|
37
|
+
client.subdomain = @subdomain
|
|
38
|
+
client.cookies = @cookies
|
|
39
|
+
client.reset_her_api
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def reset_her_api
|
|
43
|
+
client.reset_her_api
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def fetch_cookie_from_gist
|
|
47
|
+
begin
|
|
48
|
+
require 'net/http'
|
|
49
|
+
require 'uri'
|
|
50
|
+
require 'json'
|
|
51
|
+
require 'openssl'
|
|
52
|
+
|
|
53
|
+
puts "Fetching cookie from gist URL: #{@gist_url}" if ENV['DEBUG']
|
|
54
|
+
|
|
55
|
+
uri = URI.parse(@gist_url)
|
|
56
|
+
response = Net::HTTP.get_response(uri)
|
|
57
|
+
|
|
58
|
+
if response.code == '200'
|
|
59
|
+
json_data = JSON.parse(response.body)
|
|
60
|
+
|
|
61
|
+
if json_data && json_data['followupboss.com']
|
|
62
|
+
cookie_value = json_data['followupboss.com']
|
|
63
|
+
puts "Found cookie data (#{cookie_value.length} chars)" if ENV['DEBUG']
|
|
64
|
+
|
|
65
|
+
if cookie_value.length > 1000 && cookie_value.match?(/^[0-9a-fA-F]+$/)
|
|
66
|
+
puts 'Data appears to be encrypted hex, attempting decryption...' if ENV['DEBUG']
|
|
67
|
+
decrypted_cookie = decrypt_cookie(cookie_value)
|
|
68
|
+
|
|
69
|
+
if decrypted_cookie && !decrypted_cookie.empty?
|
|
70
|
+
processed_cookie = process_decrypted_data(decrypted_cookie)
|
|
71
|
+
self.cookies = processed_cookie
|
|
72
|
+
if ENV['DEBUG']
|
|
73
|
+
puts "Successfully decrypted and processed cookie from gist (#{processed_cookie.length} chars)"
|
|
74
|
+
end
|
|
75
|
+
return true
|
|
76
|
+
else
|
|
77
|
+
puts 'Failed to decrypt cookie from GIST' if ENV['DEBUG']
|
|
78
|
+
return false
|
|
79
|
+
end
|
|
80
|
+
else
|
|
81
|
+
puts 'Data appears to be unencrypted cookie string, using directly' if ENV['DEBUG']
|
|
82
|
+
self.cookies = cookie_value
|
|
83
|
+
puts "Successfully loaded unencrypted cookie from gist (#{cookie_value.length} chars)" if ENV['DEBUG']
|
|
84
|
+
return true
|
|
85
|
+
end
|
|
86
|
+
elsif ENV['DEBUG']
|
|
87
|
+
puts 'Invalid cookie data format in gist'
|
|
88
|
+
end
|
|
89
|
+
elsif ENV['DEBUG']
|
|
90
|
+
puts "Failed to fetch gist data: HTTP #{response.code}"
|
|
91
|
+
end
|
|
92
|
+
rescue StandardError => e
|
|
93
|
+
puts "Error fetching from gist: #{e.message}" if ENV['DEBUG']
|
|
94
|
+
puts e.backtrace.join("\n") if ENV['DEBUG']
|
|
95
|
+
return false
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
puts 'Failed to fetch or decrypt cookie from GIST' if ENV['DEBUG']
|
|
99
|
+
false
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def decrypt_cookie(encrypted_hex)
|
|
105
|
+
result = try_aes_cbc_decrypt(encrypted_hex)
|
|
106
|
+
|
|
107
|
+
if result
|
|
108
|
+
puts 'Successfully decrypted using kevast-encrypt method' if ENV['DEBUG']
|
|
109
|
+
result
|
|
110
|
+
else
|
|
111
|
+
puts 'Kevast-encrypt decryption failed' if ENV['DEBUG']
|
|
112
|
+
nil
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def try_aes_cbc_decrypt(encrypted_hex)
|
|
117
|
+
encrypted_data = [encrypted_hex].pack('H*')
|
|
118
|
+
|
|
119
|
+
cipher = OpenSSL::Cipher.new('AES-128-CBC')
|
|
120
|
+
cipher.decrypt
|
|
121
|
+
|
|
122
|
+
key_size = 16
|
|
123
|
+
iv_size = 16
|
|
124
|
+
|
|
125
|
+
derived_key_iv = openssl_derive_bytes(@encryption_key, nil, key_size + iv_size)
|
|
126
|
+
|
|
127
|
+
key = derived_key_iv[0, key_size]
|
|
128
|
+
iv = derived_key_iv[key_size, iv_size]
|
|
129
|
+
|
|
130
|
+
cipher.key = key
|
|
131
|
+
cipher.iv = iv
|
|
132
|
+
|
|
133
|
+
decrypted = cipher.update(encrypted_data) + cipher.final
|
|
134
|
+
decrypted.force_encoding('UTF-8')
|
|
135
|
+
|
|
136
|
+
puts 'Kevast-encrypt compatible decryption successful' if ENV['DEBUG']
|
|
137
|
+
decrypted
|
|
138
|
+
rescue StandardError => e
|
|
139
|
+
puts "Kevast-encrypt compatible decryption failed: #{e.message}" if ENV['DEBUG']
|
|
140
|
+
nil
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def openssl_derive_bytes(password, salt, key_len)
|
|
144
|
+
d = d_i = ''
|
|
145
|
+
while d.length < key_len
|
|
146
|
+
d_i = OpenSSL::Digest::MD5.digest(d_i + password + (salt || ''))
|
|
147
|
+
d += d_i
|
|
148
|
+
end
|
|
149
|
+
d[0, key_len]
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def process_decrypted_data(decrypted_data)
|
|
153
|
+
begin
|
|
154
|
+
cookies_array = JSON.parse(decrypted_data)
|
|
155
|
+
|
|
156
|
+
if cookies_array.is_a?(Array)
|
|
157
|
+
puts "Processing #{cookies_array.length} cookie objects from JSON" if ENV['DEBUG']
|
|
158
|
+
|
|
159
|
+
cookie_parts = []
|
|
160
|
+
|
|
161
|
+
cookies_array.each do |cookie_obj|
|
|
162
|
+
next unless cookie_obj.is_a?(Hash) && cookie_obj['name'] && cookie_obj['value']
|
|
163
|
+
|
|
164
|
+
domain = cookie_obj['domain'] || ''
|
|
165
|
+
if domain.include?('followupboss.com') || domain.include?('.followupboss.com') || domain.empty?
|
|
166
|
+
cookie_parts << "#{cookie_obj['name']}=#{cookie_obj['value']}"
|
|
167
|
+
puts "Added cookie: #{cookie_obj['name']} (domain: '#{domain}')" if ENV['DEBUG']
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
if cookie_parts.any?
|
|
172
|
+
cookie_string = cookie_parts.join('; ')
|
|
173
|
+
if ENV['DEBUG']
|
|
174
|
+
puts "Created cookie string with #{cookie_parts.length} cookies (#{cookie_string.length} chars)"
|
|
175
|
+
end
|
|
176
|
+
return cookie_string
|
|
177
|
+
elsif ENV['DEBUG']
|
|
178
|
+
puts 'No followupboss.com cookies found in JSON data'
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
rescue JSON::ParserError => e
|
|
182
|
+
puts "Data is not JSON, treating as plain cookie string: #{e.message}" if ENV['DEBUG']
|
|
183
|
+
rescue StandardError => e
|
|
184
|
+
puts "Error processing decrypted data: #{e.message}" if ENV['DEBUG']
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
decrypted_data
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
module FubClient
|
|
2
|
+
class Deal < Resource
|
|
3
|
+
collection_path 'deals'
|
|
4
|
+
root_element :deal
|
|
5
|
+
include_root_in_json true
|
|
6
|
+
|
|
7
|
+
scope :by_stage, ->(stage_id) { where(stageId: stage_id) }
|
|
8
|
+
scope :for_person, ->(person_id) { where(personId: person_id) }
|
|
9
|
+
scope :closing_between, lambda { |start_date, end_date|
|
|
10
|
+
where(closeDateStart: start_date, closeDateEnd: end_date)
|
|
11
|
+
}
|
|
12
|
+
scope :by_price_range, ->(min, max) { where(minPrice: min, maxPrice: max) }
|
|
13
|
+
scope :assigned_to, ->(user_id) { where(assignedTo: user_id) }
|
|
14
|
+
|
|
15
|
+
def self.active
|
|
16
|
+
where(status: 'active')
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.won
|
|
20
|
+
where(status: 'won')
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.lost
|
|
24
|
+
where(status: 'lost')
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def people
|
|
28
|
+
return [] unless respond_to?(:person_ids) && person_ids.is_a?(Array)
|
|
29
|
+
|
|
30
|
+
person_ids.map do |id|
|
|
31
|
+
FubClient::Person.find(id)
|
|
32
|
+
end.compact
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def property
|
|
36
|
+
return nil unless respond_to?(:property_id) && property_id
|
|
37
|
+
|
|
38
|
+
FubClient::Property.find(property_id)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
module FubClient
|
|
2
|
+
class DealAttachment < Resource
|
|
3
|
+
collection_path 'dealAttachments'
|
|
4
|
+
root_element :deal_attachment
|
|
5
|
+
include_root_in_json true
|
|
6
|
+
|
|
7
|
+
# Convenience method to find attachments by deal
|
|
8
|
+
scope :for_deal, ->(deal_id) { where(dealId: deal_id) }
|
|
9
|
+
|
|
10
|
+
# Convenience method to find attachments by type
|
|
11
|
+
scope :by_type, ->(type) { where(type: type) }
|
|
12
|
+
|
|
13
|
+
# Convenience method to find attachments by name (partial match)
|
|
14
|
+
scope :by_name, ->(name) { where(q: name) }
|
|
15
|
+
|
|
16
|
+
# Helper method to get the deal for this attachment
|
|
17
|
+
def deal
|
|
18
|
+
return nil unless respond_to?(:deal_id) && deal_id
|
|
19
|
+
|
|
20
|
+
FubClient::Deal.find(deal_id)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Upload a new attachment for a deal
|
|
24
|
+
def self.upload(deal_id, file_path, name = nil, type = nil, description = nil)
|
|
25
|
+
# This would typically be implemented with a multipart form,
|
|
26
|
+
# but we'll just define the interface here
|
|
27
|
+
params = {
|
|
28
|
+
dealId: deal_id,
|
|
29
|
+
file: File.new(file_path, 'rb')
|
|
30
|
+
}
|
|
31
|
+
params[:name] = name if name
|
|
32
|
+
params[:type] = type if type
|
|
33
|
+
params[:description] = description if description
|
|
34
|
+
|
|
35
|
+
post('', params)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Download the attachment content
|
|
39
|
+
def download
|
|
40
|
+
return nil unless id
|
|
41
|
+
|
|
42
|
+
begin
|
|
43
|
+
self.class.get("#{id}/download")
|
|
44
|
+
rescue StandardError
|
|
45
|
+
nil
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Delete the attachment
|
|
50
|
+
def delete
|
|
51
|
+
return false unless id
|
|
52
|
+
|
|
53
|
+
begin
|
|
54
|
+
self.class.delete(id.to_s)
|
|
55
|
+
true
|
|
56
|
+
rescue StandardError
|
|
57
|
+
false
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
module FubClient
|
|
2
|
+
class DealCustomField < Resource
|
|
3
|
+
collection_path 'dealCustomFields'
|
|
4
|
+
root_element :deal_custom_field
|
|
5
|
+
include_root_in_json true
|
|
6
|
+
|
|
7
|
+
# Convenience method to find custom fields by type
|
|
8
|
+
scope :by_type, ->(type) { where(type: type) }
|
|
9
|
+
|
|
10
|
+
# Convenience method to find custom fields by name (partial match)
|
|
11
|
+
scope :by_name, ->(name) { where(q: name) }
|
|
12
|
+
|
|
13
|
+
# Convenience method to find active custom fields
|
|
14
|
+
def self.active
|
|
15
|
+
where(active: true)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Convenience method to find inactive custom fields
|
|
19
|
+
def self.inactive
|
|
20
|
+
where(active: false)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Update a custom field
|
|
24
|
+
def update(attributes)
|
|
25
|
+
return false unless id
|
|
26
|
+
|
|
27
|
+
begin
|
|
28
|
+
self.class.put(id.to_s, attributes)
|
|
29
|
+
true
|
|
30
|
+
rescue StandardError
|
|
31
|
+
false
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Delete a custom field
|
|
36
|
+
def delete
|
|
37
|
+
return false unless id
|
|
38
|
+
|
|
39
|
+
begin
|
|
40
|
+
self.class.delete(id.to_s)
|
|
41
|
+
true
|
|
42
|
+
rescue StandardError
|
|
43
|
+
false
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
module FubClient
|
|
2
|
+
class Group < Resource
|
|
3
|
+
collection_path 'groups'
|
|
4
|
+
root_element :group
|
|
5
|
+
include_root_in_json true
|
|
6
|
+
|
|
7
|
+
# Convenience method to find groups by name (partial match)
|
|
8
|
+
scope :by_name, ->(name) { where(q: name) }
|
|
9
|
+
|
|
10
|
+
# Convenience method to find active groups
|
|
11
|
+
def self.active
|
|
12
|
+
where(active: true)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Get members of this group
|
|
16
|
+
def members
|
|
17
|
+
return [] unless id
|
|
18
|
+
|
|
19
|
+
begin
|
|
20
|
+
response = self.class.get("#{id}/members")
|
|
21
|
+
response[:members] || []
|
|
22
|
+
rescue StandardError
|
|
23
|
+
[]
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Add a user to this group
|
|
28
|
+
def add_user(user_id)
|
|
29
|
+
return false unless id && user_id
|
|
30
|
+
|
|
31
|
+
begin
|
|
32
|
+
self.class.post("#{id}/members", { userId: user_id })
|
|
33
|
+
true
|
|
34
|
+
rescue StandardError
|
|
35
|
+
false
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Remove a user from this group
|
|
40
|
+
def remove_user(user_id)
|
|
41
|
+
return false unless id && user_id
|
|
42
|
+
|
|
43
|
+
begin
|
|
44
|
+
self.class.delete("#{id}/members/#{user_id}")
|
|
45
|
+
true
|
|
46
|
+
rescue StandardError
|
|
47
|
+
false
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Get round-robin distribution information
|
|
52
|
+
def self.round_robin
|
|
53
|
+
get('roundRobin')
|
|
54
|
+
rescue StandardError
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# Her gem compatibility patch for Faraday 2.x
|
|
2
|
+
|
|
3
|
+
if defined?(Faraday) && !defined?(Faraday::Response::Middleware)
|
|
4
|
+
middleware_class = Class.new(Faraday::Middleware) do
|
|
5
|
+
def initialize(app, options = {})
|
|
6
|
+
super(app)
|
|
7
|
+
@options = options
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
Faraday::Response.const_set('Middleware', middleware_class)
|
|
12
|
+
puts '[FubClient] Created Faraday::Response::Middleware using const_set'
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
original_require = method(:require)
|
|
16
|
+
|
|
17
|
+
define_method(:require) do |name|
|
|
18
|
+
case name
|
|
19
|
+
when 'her/middleware/parse_json'
|
|
20
|
+
module Her
|
|
21
|
+
module Middleware
|
|
22
|
+
class ParseJSON < Faraday::Middleware
|
|
23
|
+
def initialize(app, options = {})
|
|
24
|
+
super(app)
|
|
25
|
+
@options = options
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def on_complete(env)
|
|
29
|
+
return unless process_response_type?(env[:response_headers]['content-type'])
|
|
30
|
+
|
|
31
|
+
env[:body] = parse(env[:body])
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def parse(body)
|
|
37
|
+
MultiJson.load(body, symbolize_keys: true)
|
|
38
|
+
rescue MultiJson::ParseError => e
|
|
39
|
+
{
|
|
40
|
+
error: "JSON parsing error: #{e.message}",
|
|
41
|
+
body: body
|
|
42
|
+
}
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def process_response_type?(content_type)
|
|
46
|
+
content_type && content_type.match(%r{application/json})
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
puts '[FubClient] Intercepted and replaced her/middleware/parse_json'
|
|
52
|
+
true
|
|
53
|
+
when 'her/middleware/first_level_parse_json'
|
|
54
|
+
module Her
|
|
55
|
+
module Middleware
|
|
56
|
+
class FirstLevelParseJSON < Faraday::Middleware
|
|
57
|
+
def initialize(app, options = {})
|
|
58
|
+
super(app)
|
|
59
|
+
@options = options
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def on_complete(env)
|
|
63
|
+
return unless process_response_type?(env[:response_headers]['content-type'])
|
|
64
|
+
|
|
65
|
+
env[:body] = parse(env[:body])
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def parse(body)
|
|
71
|
+
MultiJson.load(body)
|
|
72
|
+
rescue MultiJson::ParseError => e
|
|
73
|
+
{
|
|
74
|
+
error: "JSON parsing error: #{e.message}",
|
|
75
|
+
body: body
|
|
76
|
+
}
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def process_response_type?(content_type)
|
|
80
|
+
content_type && content_type.match(%r{application/json})
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
puts '[FubClient] Intercepted and replaced her/middleware/first_level_parse_json'
|
|
86
|
+
true
|
|
87
|
+
when %r{^her/middleware/}
|
|
88
|
+
begin
|
|
89
|
+
original_require.call(name)
|
|
90
|
+
rescue NameError => e
|
|
91
|
+
raise e unless e.message.include?('Faraday::Response::Middleware')
|
|
92
|
+
|
|
93
|
+
puts "[FubClient] Skipped loading #{name} due to Faraday::Response::Middleware error"
|
|
94
|
+
true
|
|
95
|
+
end
|
|
96
|
+
else
|
|
97
|
+
original_require.call(name)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
puts '[FubClient] Applied Her gem require interception patches'
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
module FubClient
|
|
2
|
+
class Identity < Resource
|
|
3
|
+
collection_path 'identity'
|
|
4
|
+
root_element :identity
|
|
5
|
+
include_root_in_json true
|
|
6
|
+
|
|
7
|
+
def self.current
|
|
8
|
+
get('')
|
|
9
|
+
rescue StandardError
|
|
10
|
+
nil
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def user
|
|
14
|
+
return nil unless respond_to?(:user_id) && user_id
|
|
15
|
+
|
|
16
|
+
FubClient::User.find(user_id)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def teams
|
|
20
|
+
return [] unless respond_to?(:team_ids) && team_ids.is_a?(Array)
|
|
21
|
+
|
|
22
|
+
team_ids.map do |id|
|
|
23
|
+
FubClient::Team.find(id)
|
|
24
|
+
end.compact
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def has_permission?(permission)
|
|
28
|
+
return false unless respond_to?(:permissions) && permissions.is_a?(Array)
|
|
29
|
+
|
|
30
|
+
permissions.include?(permission)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
module FubClient
|
|
2
|
+
class Message < Resource
|
|
3
|
+
collection_path 'messages'
|
|
4
|
+
root_element :message
|
|
5
|
+
include_root_in_json true
|
|
6
|
+
|
|
7
|
+
scope :for_person, ->(person_id) { where(personId: person_id) }
|
|
8
|
+
scope :by_type, ->(type) { where(type: type) }
|
|
9
|
+
scope :sent_between, lambda { |start_date, end_date|
|
|
10
|
+
where(startDate: start_date, endDate: end_date)
|
|
11
|
+
}
|
|
12
|
+
scope :inbound, -> { where(direction: 'inbound') }
|
|
13
|
+
scope :outbound, -> { where(direction: 'outbound') }
|
|
14
|
+
scope :by_user, ->(user_id) { where(userId: user_id) }
|
|
15
|
+
scope :search, ->(query) { where(q: query) }
|
|
16
|
+
|
|
17
|
+
def self.unread
|
|
18
|
+
where(read: false)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.with_attachments
|
|
22
|
+
where(hasAttachments: true)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def mark_as_read
|
|
26
|
+
self.class.put("#{id}/read", {})
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def person
|
|
30
|
+
return nil unless respond_to?(:person_id) && person_id
|
|
31
|
+
|
|
32
|
+
FubClient::Person.find(person_id)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def user
|
|
36
|
+
return nil unless respond_to?(:user_id) && user_id
|
|
37
|
+
|
|
38
|
+
FubClient::User.find(user_id)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
module FubClient
|
|
2
|
+
module Middleware
|
|
3
|
+
class Authentication < Faraday::Middleware
|
|
4
|
+
def call(env)
|
|
5
|
+
# Check if cookies are being used - if so, don't add API key
|
|
6
|
+
if FubClient::Client.instance.use_cookies?
|
|
7
|
+
puts 'Skipping API key auth since cookies are being used' if ENV['DEBUG']
|
|
8
|
+
else
|
|
9
|
+
# Get the API key from the client
|
|
10
|
+
api_key = FubClient::Client.instance.api_key
|
|
11
|
+
|
|
12
|
+
# Format the authorization header - API key as username with blank password
|
|
13
|
+
# This follows the exact format seen working in the curl command
|
|
14
|
+
auth_encoded = Base64.strict_encode64("#{api_key}:")
|
|
15
|
+
env[:request_headers]['Authorization'] = "Basic #{auth_encoded}"
|
|
16
|
+
|
|
17
|
+
# Debug - remove in production
|
|
18
|
+
puts "Debug Authentication Header: #{env[:request_headers]['Authorization']}" if ENV['DEBUG']
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Call the next middleware in the chain
|
|
22
|
+
@app.call(env)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
module FubClient
|
|
2
|
+
module Middleware
|
|
3
|
+
class CookieAuthentication < Faraday::Middleware
|
|
4
|
+
def call(env)
|
|
5
|
+
# Get the cookies from the client
|
|
6
|
+
cookies = FubClient::Client.instance.cookies
|
|
7
|
+
|
|
8
|
+
if cookies && !cookies.empty?
|
|
9
|
+
# CRITICAL: Must set a request header to enable cookie-based auth
|
|
10
|
+
# and prevent falling back to API key auth
|
|
11
|
+
|
|
12
|
+
# First, remove any Authorization header that might be added elsewhere
|
|
13
|
+
env[:request_headers].delete('Authorization')
|
|
14
|
+
|
|
15
|
+
# Add cookies as a request header (just like curl -b)
|
|
16
|
+
env[:request_headers]['Cookie'] = cookies
|
|
17
|
+
|
|
18
|
+
# Add other required headers as seen in the working curl example
|
|
19
|
+
env[:request_headers]['X-Requested-With'] = 'XMLHttpRequest'
|
|
20
|
+
env[:request_headers]['X-System'] = 'fub-spa'
|
|
21
|
+
env[:request_headers]['Accept'] = 'application/json, text/javascript, */*; q=0.01'
|
|
22
|
+
env[:request_headers]['Accept-Language'] = 'en-US,en;q=0.9'
|
|
23
|
+
env[:request_headers]['User-Agent'] =
|
|
24
|
+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36'
|
|
25
|
+
|
|
26
|
+
if ENV['DEBUG']
|
|
27
|
+
puts "Using cookies for authentication (#{cookies.length} chars)"
|
|
28
|
+
puts "Cookie starts with: #{cookies[0..50]}..." if cookies.length > 50
|
|
29
|
+
puts "Request URL: #{env[:url]}"
|
|
30
|
+
|
|
31
|
+
# Remove any API key that might have been added
|
|
32
|
+
# This is to diagnose if we're seeing API key auth being used
|
|
33
|
+
api_key = env[:request_headers]['Authorization']
|
|
34
|
+
if api_key
|
|
35
|
+
puts "WARNING: Authorization header still present: #{api_key}"
|
|
36
|
+
puts 'Removing Authorization header before request'
|
|
37
|
+
env[:request_headers].delete('Authorization')
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
elsif ENV['DEBUG']
|
|
41
|
+
puts 'Warning: No cookies available for authentication'
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Debug the request one more time
|
|
45
|
+
if ENV['DEBUG']
|
|
46
|
+
puts 'Final request headers:'
|
|
47
|
+
env[:request_headers].each do |k, v|
|
|
48
|
+
if k.downcase == 'cookie' && v.length > 50
|
|
49
|
+
puts " #{k}: #{v[0..50]}..."
|
|
50
|
+
else
|
|
51
|
+
puts " #{k}: #{v}"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Call the next middleware in the chain
|
|
57
|
+
@app.call(env)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|