forest_admin_datasource_intercom 1.49.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.
Files changed (52) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/LICENSE +202 -0
  4. data/README.md +811 -0
  5. data/Rakefile +6 -0
  6. data/exe/forest_admin_intercom_probe +287 -0
  7. data/forest_admin_datasource_intercom.gemspec +38 -0
  8. data/lib/forest_admin_datasource_intercom/cache.rb +84 -0
  9. data/lib/forest_admin_datasource_intercom/client.rb +546 -0
  10. data/lib/forest_admin_datasource_intercom/collections/admin.rb +83 -0
  11. data/lib/forest_admin_datasource_intercom/collections/base_collection.rb +161 -0
  12. data/lib/forest_admin_datasource_intercom/collections/company/serializer.rb +44 -0
  13. data/lib/forest_admin_datasource_intercom/collections/company.rb +69 -0
  14. data/lib/forest_admin_datasource_intercom/collections/contact/serializer.rb +106 -0
  15. data/lib/forest_admin_datasource_intercom/collections/contact.rb +266 -0
  16. data/lib/forest_admin_datasource_intercom/collections/contact_identity.rb +77 -0
  17. data/lib/forest_admin_datasource_intercom/collections/conversation/serializer.rb +75 -0
  18. data/lib/forest_admin_datasource_intercom/collections/conversation/timeline.rb +40 -0
  19. data/lib/forest_admin_datasource_intercom/collections/conversation.rb +153 -0
  20. data/lib/forest_admin_datasource_intercom/collections/cursor_collection.rb +363 -0
  21. data/lib/forest_admin_datasource_intercom/collections/custom_attributes.rb +55 -0
  22. data/lib/forest_admin_datasource_intercom/collections/fetch_all_collection.rb +196 -0
  23. data/lib/forest_admin_datasource_intercom/collections/offset_collection.rb +311 -0
  24. data/lib/forest_admin_datasource_intercom/collections/records_by_id.rb +97 -0
  25. data/lib/forest_admin_datasource_intercom/collections/relations.rb +294 -0
  26. data/lib/forest_admin_datasource_intercom/collections/team.rb +70 -0
  27. data/lib/forest_admin_datasource_intercom/collections/team_membership.rb +51 -0
  28. data/lib/forest_admin_datasource_intercom/collections/ticket/derived_columns.rb +130 -0
  29. data/lib/forest_admin_datasource_intercom/collections/ticket/serializer.rb +70 -0
  30. data/lib/forest_admin_datasource_intercom/collections/ticket.rb +143 -0
  31. data/lib/forest_admin_datasource_intercom/collections/ticket_state.rb +40 -0
  32. data/lib/forest_admin_datasource_intercom/collections/ticket_type.rb +46 -0
  33. data/lib/forest_admin_datasource_intercom/collections/timeline.rb +40 -0
  34. data/lib/forest_admin_datasource_intercom/configuration.rb +226 -0
  35. data/lib/forest_admin_datasource_intercom/datasource.rb +78 -0
  36. data/lib/forest_admin_datasource_intercom/pagination/cursor_walker.rb +123 -0
  37. data/lib/forest_admin_datasource_intercom/query/caller_zone.rb +48 -0
  38. data/lib/forest_admin_datasource_intercom/query/condition_tree_translator.rb +169 -0
  39. data/lib/forest_admin_datasource_intercom/query/day_bounds.rb +75 -0
  40. data/lib/forest_admin_datasource_intercom/query/filter_value.rb +177 -0
  41. data/lib/forest_admin_datasource_intercom/query/operator_table.rb +54 -0
  42. data/lib/forest_admin_datasource_intercom/query/search_fields.rb +225 -0
  43. data/lib/forest_admin_datasource_intercom/query/search_fields.yml +663 -0
  44. data/lib/forest_admin_datasource_intercom/rate_limiter.rb +148 -0
  45. data/lib/forest_admin_datasource_intercom/retry_policy.rb +95 -0
  46. data/lib/forest_admin_datasource_intercom/schema/attribute_naming.rb +52 -0
  47. data/lib/forest_admin_datasource_intercom/schema/data_attributes_introspector.rb +88 -0
  48. data/lib/forest_admin_datasource_intercom/schema/ticket_attributes_introspector.rb +133 -0
  49. data/lib/forest_admin_datasource_intercom/throttle.rb +19 -0
  50. data/lib/forest_admin_datasource_intercom/version.rb +3 -0
  51. data/lib/forest_admin_datasource_intercom.rb +57 -0
  52. metadata +180 -0
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,287 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Enumerates what Intercom's search endpoints really filter, and with which
5
+ # operators, against a real workspace. The answer is what `search_fields.yml`
6
+ # holds and what the schema derives its filters from -- and it cannot be read
7
+ # off the documentation: measured during lot 1, `/tickets/search` refuses
8
+ # `company_id` with `invalid_field` although a ticket carries one.
9
+ #
10
+ # INTERCOM_ACCESS_TOKEN=... forest_admin_intercom_probe --endpoint tickets
11
+ # INTERCOM_ACCESS_TOKEN=... forest_admin_intercom_probe --region eu --out measured.yml
12
+ #
13
+ # Shipped with the gem rather than left in the repository: what it measures is
14
+ # the customer's workspace, and the operator running it has the gem installed
15
+ # and not a clone of the agent.
16
+ #
17
+ # It sends one search per (field, operator) cell, asking for a single record,
18
+ # and reads Intercom's refusal codes: `invalid_field` for a field the endpoint
19
+ # does not filter at all -- the rest of its row is then skipped -- `data_invalid`
20
+ # for an operator it refuses on that field, `invalid_value` for a value shape it
21
+ # refuses. Read-only: a search changes nothing, and no payload is printed or
22
+ # written, only field names, operators and error codes.
23
+ #
24
+ # What it writes with `--out` is *evidence*, deliberately not the table itself:
25
+ # `search_fields.yml` carries the prose that says why a column stays refused,
26
+ # and a generated file would drop it. Merge the measurements into the table by
27
+ # hand, which is also where deciding to expose a newly discovered field belongs.
28
+
29
+ $LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
30
+
31
+ require 'forest_admin_datasource_intercom'
32
+ require 'optparse'
33
+
34
+ module ProbeSearchFields
35
+ SearchFields = ForestAdminDatasourceIntercom::Query::SearchFields
36
+
37
+ # Whether this file is being run as the command rather than loaded by a
38
+ # spec. The basenames and not the paths: RubyGems installs an executable
39
+ # behind a stub of its own and `load`s this file from it, so `$PROGRAM_NAME`
40
+ # is the stub while `__FILE__` is this file -- comparing the two would make
41
+ # the shipped command exit without doing anything, which is the one thing
42
+ # shipping it was for. A spec loading it runs under `rspec`, whose basename
43
+ # is not this one.
44
+ def self.invoked_as_command?(program_name)
45
+ File.basename(program_name.to_s) == File.basename(__FILE__)
46
+ end
47
+
48
+ # A cell is probed with the value shapes its field plausibly takes, most
49
+ # likely first: a wrong shape is refused with `data_invalid` just like an
50
+ # unsupported operator, so a single attempt would report an operator as
51
+ # refused when only the value was wrong.
52
+ VALUES = {
53
+ 'date' => ['1', 1, '2026-01-01'],
54
+ 'number' => [0, '0'],
55
+ 'boolean' => [true, 'true'],
56
+ 'string' => ['forest-probe', 0],
57
+ 'text' => ['forest-probe']
58
+ }.freeze
59
+
60
+ LIST_OPERATORS = %w[IN NIN].freeze
61
+
62
+ # What Intercom answers a search it refuses: `invalid_field`, `data_invalid`
63
+ # and `invalid_value` all travel on a 400.
64
+ REFUSAL_STATUSES = [400, 422].freeze
65
+
66
+ # Raised when an answer measures nothing, so the run stops before writing.
67
+ class Inconclusive < StandardError; end
68
+
69
+ # A candidate carries no declared type -- discovering it is the point -- so
70
+ # one is guessed from its name, and the fallbacks above cover a wrong guess.
71
+ def self.guess_type(field)
72
+ case field
73
+ when /(_at|_since|_until)\z/ then 'date'
74
+ when /\Acount_|_count\z|\Atime_to_|_time\z/ then 'number'
75
+ when /\A(open|read|is_|has_|.*_participated)/ then 'boolean'
76
+ else 'string'
77
+ end
78
+ end
79
+
80
+ class Probe
81
+ def initialize(client, endpoint)
82
+ @client = client
83
+ @endpoint = endpoint
84
+ end
85
+
86
+ # Every operator of the alphabet on every field of the row: what the table
87
+ # already declares, plus the candidates. Nothing is assumed from the
88
+ # declared operators -- an operator missing from the table is exactly what
89
+ # this is here to find.
90
+ def run(field, type)
91
+ results = {}
92
+
93
+ SearchFields::KNOWN_OPERATORS.each do |operator|
94
+ outcome = probe(field, operator, type)
95
+ return { unfilterable: outcome } if outcome[:code] == 'invalid_field'
96
+
97
+ results[operator] = outcome
98
+ end
99
+
100
+ { operators: results }
101
+ end
102
+
103
+ private
104
+
105
+ def probe(field, operator, type)
106
+ last = nil
107
+
108
+ VALUES.fetch(type, VALUES['string']).each do |value|
109
+ outcome = attempt(field, operator, operator_value(operator, value))
110
+ # `invalid_field` is about the field, not about what was sent with it:
111
+ # another value shape cannot make a field the endpoint does not filter
112
+ # appear, so the row ends here rather than paying a request per shape.
113
+ return outcome if outcome[:ok] || outcome[:code] == 'invalid_field'
114
+
115
+ last = outcome
116
+ end
117
+
118
+ last
119
+ end
120
+
121
+ # Only an answer about the search itself is a measurement. A 401, a 429
122
+ # that outlasted the retries, a 5xx or a timeout says nothing about the
123
+ # field, and recorded as a refusal it would be wrong evidence.
124
+ def attempt(field, operator, value)
125
+ @client.search_page(@endpoint.path, query: { 'field' => field, 'operator' => operator, 'value' => value },
126
+ per_page: 1, list_key: list_key)
127
+ { ok: true }
128
+ rescue ForestAdminDatasourceIntercom::APIError => e
129
+ raise Inconclusive, inconclusive_message(field, operator, e) unless REFUSAL_STATUSES.include?(e.status)
130
+
131
+ { ok: false, code: error_code(e), status: e.status }
132
+ end
133
+
134
+ def inconclusive_message(field, operator, error)
135
+ "#{@endpoint.path} answered #{field} #{operator} with #{error.status ? "HTTP #{error.status}" : "no status"}, " \
136
+ 'which says nothing about the field. Nothing was written; check the token and the region, then run ' \
137
+ 'the probe again.'
138
+ end
139
+
140
+ def operator_value(operator, value)
141
+ LIST_OPERATORS.include?(operator) ? [value] : value
142
+ end
143
+
144
+ # `/tickets/search` answers under `tickets` and `/conversations/search`
145
+ # under `conversations`; a body read under the wrong key raises before the
146
+ # outcome is classified.
147
+ def list_key = @endpoint.name
148
+
149
+ def error_code(error)
150
+ errors = error.body.is_a?(Hash) ? error.body['errors'] : nil
151
+ first = Array(errors).first
152
+
153
+ (first.is_a?(Hash) ? first['code'] : nil) || "http_#{error.status}"
154
+ end
155
+ end
156
+
157
+ class Report
158
+ def initialize(endpoint)
159
+ @endpoint = endpoint
160
+ @rows = {}
161
+ end
162
+
163
+ def record(field, column, result)
164
+ @rows[field] = { 'column' => column, 'result' => result }
165
+ end
166
+
167
+ # What the run says about the committed table, which is the only reason to
168
+ # run it: an operator the table promises and Intercom refuses is a filter
169
+ # the interface offers and the read cannot honour.
170
+ def print_diff
171
+ @rows.each do |field, row|
172
+ column = row['column']
173
+ result = row['result']
174
+
175
+ if result[:unfilterable]
176
+ declared = column ? " <- table declares it on column '#{column}'" : ''
177
+ puts " #{field.ljust(42)} NOT FILTERABLE (#{result[:unfilterable][:code]})#{declared}"
178
+ next
179
+ end
180
+
181
+ print_operators(field, column, result[:operators])
182
+ end
183
+ end
184
+
185
+ def to_yaml_document
186
+ { 'endpoint' => @endpoint.name, 'path' => @endpoint.path, 'measured_at' => Time.now.utc.strftime('%Y-%m-%d'),
187
+ 'fields' => @rows.to_h { |field, row| [field, measured_row(row)] } }.to_yaml
188
+ end
189
+
190
+ private
191
+
192
+ def print_operators(field, column, outcomes)
193
+ supported = outcomes.select { |_, outcome| outcome[:ok] }.keys
194
+ declared = column ? Array(@endpoint.field(column)&.operators) : []
195
+ puts " #{field.ljust(42)} #{supported.empty? ? "(no operator answered)" : supported.join(" ")}"
196
+
197
+ report_drift(declared - supported, supported - declared)
198
+ end
199
+
200
+ def report_drift(promised, discovered)
201
+ puts " ! the table promises #{promised.join(", ")} here, Intercom refuses it" unless promised.empty?
202
+ puts " + Intercom also accepts #{discovered.join(", ")}" unless discovered.empty?
203
+ end
204
+
205
+ def measured_row(row)
206
+ result = row['result']
207
+ return { 'filterable' => false, 'code' => result[:unfilterable][:code] } if result[:unfilterable]
208
+
209
+ { 'filterable' => true,
210
+ 'operators' => result[:operators].select { |_, outcome| outcome[:ok] }.keys,
211
+ 'refused' => result[:operators].reject { |_, outcome| outcome[:ok] }
212
+ .transform_values { |outcome| outcome[:code] } }
213
+ end
214
+ end
215
+
216
+ class CLI
217
+ def self.call(argv)
218
+ new(options(argv)).run
219
+ end
220
+
221
+ def self.options(argv)
222
+ options = { endpoints: SearchFields.endpoints, region: 'us', token: ENV.fetch('INTERCOM_ACCESS_TOKEN', nil) }
223
+
224
+ OptionParser.new do |parser|
225
+ parser.banner = 'Usage: INTERCOM_ACCESS_TOKEN=... forest_admin_intercom_probe [options]'
226
+ parser.on('--endpoint NAME', "one of #{SearchFields.endpoints.join(", ")}") { |v| options[:endpoints] = [v] }
227
+ parser.on('--region NAME', 'us (default), eu or au') { |v| options[:region] = v }
228
+ parser.on('--token TOKEN', 'defaults to $INTERCOM_ACCESS_TOKEN') { |v| options[:token] = v }
229
+ parser.on('--out PATH', 'write the measurements as YAML evidence') { |v| options[:out] = v }
230
+ end.parse!(argv)
231
+
232
+ options
233
+ end
234
+
235
+ def initialize(options)
236
+ @options = options
237
+ end
238
+
239
+ def run
240
+ abort('No token: set INTERCOM_ACCESS_TOKEN or pass --token.') if @options[:token].to_s.empty?
241
+
242
+ documents = @options[:endpoints].map { |name| probe_endpoint(SearchFields.fetch(name)) }
243
+ write(documents) if @options[:out]
244
+ 0
245
+ rescue Inconclusive => e
246
+ abort("Probe stopped: #{e.message}")
247
+ end
248
+
249
+ private
250
+
251
+ def probe_endpoint(endpoint)
252
+ puts "\n#{endpoint.path} -- #{endpoint.fields.size} declared field(s), #{endpoint.candidates.size} candidate(s)"
253
+ probe = Probe.new(client, endpoint)
254
+ report = Report.new(endpoint)
255
+
256
+ targets(endpoint).each { |field, column, type| report.record(field, column, probe.run(field, type)) }
257
+ report.print_diff
258
+ report.to_yaml_document
259
+ end
260
+
261
+ # The declared fields first, with the type the table gives them; then the
262
+ # candidates, whose type is guessed. A candidate already covered by a
263
+ # declared field is not probed twice.
264
+ def targets(endpoint)
265
+ declared = endpoint.fields.values.map { |field| [field.field, field.column, field.type] }
266
+ candidates = (endpoint.candidates - declared.map(&:first)).map { |field| [field, nil, guess_type(field)] }
267
+
268
+ declared + candidates
269
+ end
270
+
271
+ def guess_type(field) = ProbeSearchFields.guess_type(field)
272
+
273
+ def client
274
+ @client ||= ForestAdminDatasourceIntercom::Client.new(
275
+ ForestAdminDatasourceIntercom::Configuration.new(access_token: @options[:token], region: @options[:region])
276
+ )
277
+ end
278
+
279
+ def write(documents)
280
+ File.write(@options[:out], documents.join("\n"))
281
+ puts "\nWritten to #{@options[:out]}. Merge the measurements into search_fields.yml by hand: " \
282
+ 'the table carries the prose a generated file would drop.'
283
+ end
284
+ end
285
+ end
286
+
287
+ exit(ProbeSearchFields::CLI.call(ARGV)) if ProbeSearchFields.invoked_as_command?($PROGRAM_NAME)
@@ -0,0 +1,38 @@
1
+ lib = File.expand_path('lib', __dir__)
2
+ $LOAD_PATH.unshift lib unless $LOAD_PATH.include?(lib)
3
+
4
+ require_relative 'lib/forest_admin_datasource_intercom/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = 'forest_admin_datasource_intercom'
8
+ spec.version = ForestAdminDatasourceIntercom::VERSION
9
+ spec.authors = ['Forest Admin']
10
+ spec.email = ['contact@forestadmin.com']
11
+ spec.homepage = 'https://www.forestadmin.com'
12
+ spec.summary = 'Intercom datasource for Forest Admin Ruby agent.'
13
+ spec.description = 'Surface Intercom conversations, tickets, contacts and companies as Forest Admin collections.'
14
+ spec.license = 'Apache-2.0'
15
+ spec.required_ruby_version = '>= 3.0.0'
16
+
17
+ spec.metadata['homepage_uri'] = spec.homepage
18
+ spec.metadata['source_code_uri'] = 'https://github.com/ForestAdmin/agent-ruby'
19
+ spec.metadata['changelog_uri'] = 'https://github.com/ForestAdmin/agent-ruby/blob/main/CHANGELOG.md'
20
+ spec.metadata['rubygems_mfa_required'] = 'false'
21
+
22
+ spec.files = Dir.chdir(__dir__) do
23
+ `git ls-files -z`.split("\x0").reject do |f|
24
+ (File.expand_path(f) == __FILE__) ||
25
+ f.start_with?(*%w[bin/ test/ spec/ features/ .git .circleci appveyor Gemfile])
26
+ end
27
+ end
28
+ spec.bindir = 'exe'
29
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
30
+ spec.require_paths = ['lib']
31
+
32
+ spec.add_dependency 'activesupport', '>= 6.1'
33
+ spec.add_dependency 'faraday', '~> 2.0'
34
+ spec.add_dependency 'faraday-net_http_persistent', '~> 2.0'
35
+ spec.add_dependency 'faraday-retry', '~> 2.0'
36
+ spec.add_dependency 'forest_admin_datasource_toolkit', ">= #{ForestAdminDatasourceIntercom::VERSION}"
37
+ spec.add_dependency 'zeitwerk', '~> 2.3'
38
+ end
@@ -0,0 +1,84 @@
1
+ module ForestAdminDatasourceIntercom
2
+ # TTL store for workspace reference reads (teammates, teams, ticket types, ticket states), re-read
3
+ # on every page otherwise. Records are never cached here; see `Client#with_read_scope` for per-page dedup.
4
+ class Cache
5
+ Entry = Struct.new(:value, :expires_at)
6
+
7
+ # Monotonic rather than wall clock: this measures an elapsed span, and a
8
+ # host whose clock steps -- an NTP correction, a suspend -- would otherwise
9
+ # expire every entry at once or hold them all far past the window.
10
+ def initialize(ttl:, now: nil)
11
+ @ttl = ttl.to_f
12
+ @now = now || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
13
+ @mutex = Mutex.new
14
+ @entries = {}
15
+ # Bumped by `clear`, and carried by every read that is about to fetch, so
16
+ # a response already in flight when the store was dropped cannot land in
17
+ # it afterwards. See `fetch`.
18
+ @generation = 0
19
+ end
20
+
21
+ def enabled? = @ttl.positive?
22
+
23
+ # The block runs outside the mutex (it is an HTTP request): two concurrent misses both read, which is
24
+ # cheaper than serializing every thread. A raising block writes nothing, and the generation is read
25
+ # before the block so a response in flight across `clear` is not stored, nor one `store` declines.
26
+ def fetch(key, store: -> { true })
27
+ return yield unless enabled?
28
+
29
+ hit = read(key)
30
+ return hit.value if hit
31
+
32
+ generation = @mutex.synchronize { @generation }
33
+ value = yield
34
+ write(key, value, generation) if store.call
35
+ value
36
+ end
37
+
38
+ # Drops everything held, without waiting the window out. Nothing inside this
39
+ # package calls it: the window is what expires an entry, and no read here
40
+ # writes. It is reachable through `Configuration#reference_cache` for the
41
+ # one case the window cannot serve -- a customizer that has just written a
42
+ # team or a ticket type into the workspace and wants the next page to show
43
+ # it. Documented in the README beside the ttl.
44
+ def clear
45
+ @mutex.synchronize do
46
+ @entries.clear
47
+ @generation += 1
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ def read(key)
54
+ @mutex.synchronize do
55
+ entry = @entries[key]
56
+ next nil if entry.nil?
57
+
58
+ if entry.expires_at <= @now.call
59
+ @entries.delete(key)
60
+ next nil
61
+ end
62
+
63
+ entry
64
+ end
65
+ end
66
+
67
+ # Expired entries are dropped on the way past rather than by a sweeper: the
68
+ # keys are a handful of endpoints, so the walk is short and there is no
69
+ # thread to own.
70
+ #
71
+ # A write from before a `clear` is dropped rather than stored: the store it
72
+ # was going to land in no longer exists.
73
+ def write(key, value, generation)
74
+ now = @now.call
75
+
76
+ @mutex.synchronize do
77
+ next if generation != @generation
78
+
79
+ @entries.delete_if { |_, entry| entry.expires_at <= now }
80
+ @entries[key] = Entry.new(value, now + @ttl)
81
+ end
82
+ end
83
+ end
84
+ end