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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/LICENSE +202 -0
- data/README.md +811 -0
- data/Rakefile +6 -0
- data/exe/forest_admin_intercom_probe +287 -0
- data/forest_admin_datasource_intercom.gemspec +38 -0
- data/lib/forest_admin_datasource_intercom/cache.rb +84 -0
- data/lib/forest_admin_datasource_intercom/client.rb +546 -0
- data/lib/forest_admin_datasource_intercom/collections/admin.rb +83 -0
- data/lib/forest_admin_datasource_intercom/collections/base_collection.rb +161 -0
- data/lib/forest_admin_datasource_intercom/collections/company/serializer.rb +44 -0
- data/lib/forest_admin_datasource_intercom/collections/company.rb +69 -0
- data/lib/forest_admin_datasource_intercom/collections/contact/serializer.rb +106 -0
- data/lib/forest_admin_datasource_intercom/collections/contact.rb +266 -0
- data/lib/forest_admin_datasource_intercom/collections/contact_identity.rb +77 -0
- data/lib/forest_admin_datasource_intercom/collections/conversation/serializer.rb +75 -0
- data/lib/forest_admin_datasource_intercom/collections/conversation/timeline.rb +40 -0
- data/lib/forest_admin_datasource_intercom/collections/conversation.rb +153 -0
- data/lib/forest_admin_datasource_intercom/collections/cursor_collection.rb +363 -0
- data/lib/forest_admin_datasource_intercom/collections/custom_attributes.rb +55 -0
- data/lib/forest_admin_datasource_intercom/collections/fetch_all_collection.rb +196 -0
- data/lib/forest_admin_datasource_intercom/collections/offset_collection.rb +311 -0
- data/lib/forest_admin_datasource_intercom/collections/records_by_id.rb +97 -0
- data/lib/forest_admin_datasource_intercom/collections/relations.rb +294 -0
- data/lib/forest_admin_datasource_intercom/collections/team.rb +70 -0
- data/lib/forest_admin_datasource_intercom/collections/team_membership.rb +51 -0
- data/lib/forest_admin_datasource_intercom/collections/ticket/derived_columns.rb +130 -0
- data/lib/forest_admin_datasource_intercom/collections/ticket/serializer.rb +70 -0
- data/lib/forest_admin_datasource_intercom/collections/ticket.rb +143 -0
- data/lib/forest_admin_datasource_intercom/collections/ticket_state.rb +40 -0
- data/lib/forest_admin_datasource_intercom/collections/ticket_type.rb +46 -0
- data/lib/forest_admin_datasource_intercom/collections/timeline.rb +40 -0
- data/lib/forest_admin_datasource_intercom/configuration.rb +226 -0
- data/lib/forest_admin_datasource_intercom/datasource.rb +78 -0
- data/lib/forest_admin_datasource_intercom/pagination/cursor_walker.rb +123 -0
- data/lib/forest_admin_datasource_intercom/query/caller_zone.rb +48 -0
- data/lib/forest_admin_datasource_intercom/query/condition_tree_translator.rb +169 -0
- data/lib/forest_admin_datasource_intercom/query/day_bounds.rb +75 -0
- data/lib/forest_admin_datasource_intercom/query/filter_value.rb +177 -0
- data/lib/forest_admin_datasource_intercom/query/operator_table.rb +54 -0
- data/lib/forest_admin_datasource_intercom/query/search_fields.rb +225 -0
- data/lib/forest_admin_datasource_intercom/query/search_fields.yml +663 -0
- data/lib/forest_admin_datasource_intercom/rate_limiter.rb +148 -0
- data/lib/forest_admin_datasource_intercom/retry_policy.rb +95 -0
- data/lib/forest_admin_datasource_intercom/schema/attribute_naming.rb +52 -0
- data/lib/forest_admin_datasource_intercom/schema/data_attributes_introspector.rb +88 -0
- data/lib/forest_admin_datasource_intercom/schema/ticket_attributes_introspector.rb +133 -0
- data/lib/forest_admin_datasource_intercom/throttle.rb +19 -0
- data/lib/forest_admin_datasource_intercom/version.rb +3 -0
- data/lib/forest_admin_datasource_intercom.rb +57 -0
- metadata +180 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
module ForestAdminDatasourceIntercom
|
|
2
|
+
module Collections
|
|
3
|
+
# Read through POST /tickets/search: Intercom has no GET /tickets, and records come under `tickets`.
|
|
4
|
+
# Every response carries each ticket's whole timeline with no field selection (measured: 155 parts on one
|
|
5
|
+
# ticket), hence the small page size and the free part-derived columns.
|
|
6
|
+
class Ticket < CursorCollection
|
|
7
|
+
include ContactIdentity
|
|
8
|
+
include Ticket::Serializer
|
|
9
|
+
include Ticket::DerivedColumns
|
|
10
|
+
include CustomAttributes
|
|
11
|
+
# The same thread a conversation publishes, and free here: the parts are
|
|
12
|
+
# in the response whether or not anything asks for them.
|
|
13
|
+
include Timeline
|
|
14
|
+
|
|
15
|
+
# Intercom accepts 150. This is not that: it is what keeps one page of
|
|
16
|
+
# tickets, timelines included, a response an agent can hold and an operator
|
|
17
|
+
# can wait for. Provisional until measured against real response sizes on
|
|
18
|
+
# the customer's workspace.
|
|
19
|
+
MAX_TICKETS_PER_PAGE = 25
|
|
20
|
+
|
|
21
|
+
# `/tickets/search` demands a query, so a list view sends the least noisy
|
|
22
|
+
# predicate that matches everything. Every ticket has a creation date, and
|
|
23
|
+
# a bound at the epoch keeps whatever the day-granular truncation does to
|
|
24
|
+
# it harmless.
|
|
25
|
+
MATCH_EVERY_TICKET = { 'field' => 'created_at', 'operator' => '>', 'value' => '0' }.freeze
|
|
26
|
+
|
|
27
|
+
def initialize(datasource, attributes: [])
|
|
28
|
+
@attributes = attributes
|
|
29
|
+
super(datasource, 'IntercomTicket')
|
|
30
|
+
# Declared so the search reaches `refuse_search!` instead of being
|
|
31
|
+
# emulated by the customizer as an equality on every String column,
|
|
32
|
+
# which `/tickets/search` answers with an empty page.
|
|
33
|
+
enable_search
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
protected
|
|
37
|
+
|
|
38
|
+
def record_endpoint = 'tickets'
|
|
39
|
+
def list_key = 'tickets'
|
|
40
|
+
def searchable = 'tickets'
|
|
41
|
+
def max_page_size = MAX_TICKETS_PER_PAGE
|
|
42
|
+
|
|
43
|
+
# The part bodies are HTML written by end customers, and rendering
|
|
44
|
+
# third-party HTML inside Forest is neither safe nor useful (R10). Sent on
|
|
45
|
+
# the search, where Intercom does not document it: a parameter it ignores
|
|
46
|
+
# costs a query string, while the one it honours saves every row of the
|
|
47
|
+
# thread from coming back as markup.
|
|
48
|
+
def read_params = { 'display_as' => 'plaintext' }
|
|
49
|
+
|
|
50
|
+
# Intercom exposes no `GET /tickets`, so a list view searches too: with the
|
|
51
|
+
# filter it was given, or with the predicate that matches everything when
|
|
52
|
+
# it was given none.
|
|
53
|
+
def searchable_only? = true
|
|
54
|
+
def match_all_query = MATCH_EVERY_TICKET
|
|
55
|
+
|
|
56
|
+
def enrich(records, rows, projection)
|
|
57
|
+
embed_contact_identity(records, rows, projection)
|
|
58
|
+
embed_derived_columns(records, rows, projection)
|
|
59
|
+
embed_timeline(records, rows, projection)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def define_schema
|
|
65
|
+
add_column('id', 'String', is_primary_key: true)
|
|
66
|
+
# The number the support team says out loud, next to the id the API
|
|
67
|
+
# answers by.
|
|
68
|
+
add_column('ticket_id', 'String')
|
|
69
|
+
# Read as the label (`Customer` / `Back-office` / `Tracker`); a filter sends
|
|
70
|
+
# the word `/tickets/search` takes for it (`request` / `task` / `tracker`).
|
|
71
|
+
add_column('category', 'String')
|
|
72
|
+
add_column('open', 'Boolean')
|
|
73
|
+
add_column('is_shared', 'Boolean')
|
|
74
|
+
add_column('created_at', 'Date')
|
|
75
|
+
add_column('updated_at', 'Date')
|
|
76
|
+
add_column('admin_assignee_id', 'String')
|
|
77
|
+
add_column('team_assignee_id', 'String')
|
|
78
|
+
# The ticket carries its company as an id alone, unlike a conversation
|
|
79
|
+
# which carries the whole object: the account name would cost a request
|
|
80
|
+
# per row, so it is not offered here. Measured: the id is Intercom's own,
|
|
81
|
+
# not the customer's external one, which is what a relation will have to
|
|
82
|
+
# target in lot 4.
|
|
83
|
+
add_column('company_id', 'String')
|
|
84
|
+
define_state_columns
|
|
85
|
+
define_type_columns
|
|
86
|
+
define_contact_columns
|
|
87
|
+
define_derived_columns
|
|
88
|
+
add_column('part_count', 'Number')
|
|
89
|
+
add_column('timeline', 'Json')
|
|
90
|
+
# Before the attribute columns rather than after: a workspace attribute
|
|
91
|
+
# whose name lands on a relation is then skipped with a warning, the way
|
|
92
|
+
# one landing on a column already is. Declared after, it would collide
|
|
93
|
+
# and take the boot with it.
|
|
94
|
+
define_relations
|
|
95
|
+
register_attribute_columns
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# The state arrives embedded as a whole object, so its label costs nothing:
|
|
99
|
+
# a queue reads without a join. One label and not three -- the category and
|
|
100
|
+
# the customer-facing label are read through the `state` relation, which is
|
|
101
|
+
# where every field of a state lives. Neither of the two was ever
|
|
102
|
+
# filterable, so nothing that could be saved in a segment or a scope
|
|
103
|
+
# depended on them.
|
|
104
|
+
def define_state_columns
|
|
105
|
+
add_column('state_id', 'String')
|
|
106
|
+
add_column('state_label', 'String')
|
|
107
|
+
add_column('previous_state_id', 'String')
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def define_type_columns
|
|
111
|
+
add_column('ticket_type_id', 'String')
|
|
112
|
+
add_column('ticket_type_name', 'String')
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# /tickets/search filters on admin_assignee_id, team_assignee_id and ticket_type_id, on no state id, and
|
|
116
|
+
# `contact_ids` is unconfirmed; a traversal the endpoint cannot filter is refused by name, not dropped.
|
|
117
|
+
def define_relations
|
|
118
|
+
add_many_to_one('admin_assignee', foreign_collection: 'IntercomAdmin', foreign_key: 'admin_assignee_id')
|
|
119
|
+
add_many_to_one('team_assignee', foreign_collection: 'IntercomTeam', foreign_key: 'team_assignee_id')
|
|
120
|
+
add_many_to_one('state', foreign_collection: 'IntercomTicketState', foreign_key: 'state_id')
|
|
121
|
+
add_many_to_one('previous_state', foreign_collection: 'IntercomTicketState',
|
|
122
|
+
foreign_key: 'previous_state_id')
|
|
123
|
+
add_many_to_one('ticket_type', foreign_collection: 'IntercomTicketType', foreign_key: 'ticket_type_id')
|
|
124
|
+
add_many_to_one('contact', foreign_collection: 'IntercomContact', foreign_key: 'contact_id')
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Free: the parts come with the search response regardless. An empty list is an empty thread; a payload
|
|
128
|
+
# with no parts leaves it nil (unknown), as a listed conversation does.
|
|
129
|
+
def embed_timeline(records, rows, projection)
|
|
130
|
+
return unless column_asked?(projection, 'timeline')
|
|
131
|
+
|
|
132
|
+
records.each_with_index { |record, index| rows[index]['timeline'] = parts_of(record) && build_timeline(record) }
|
|
133
|
+
truncated = records.count { |record| truncated?(record) }
|
|
134
|
+
warn_truncated_timelines(truncated) if truncated.positive?
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# The attribute columns of every ticket type, in union. Read at boot by
|
|
138
|
+
# `TicketAttributesIntrospector`, which is also where a workspace's own
|
|
139
|
+
# name is turned into one a Forest query string can carry.
|
|
140
|
+
def attribute_kind = 'ticket'
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
module ForestAdminDatasourceIntercom
|
|
2
|
+
module Collections
|
|
3
|
+
# The ticket states of the workspace. A ticket carries its state as an id, so
|
|
4
|
+
# without this collection a support queue reads as a column of numbers.
|
|
5
|
+
class TicketState < FetchAllCollection
|
|
6
|
+
def initialize(datasource)
|
|
7
|
+
super(datasource, 'IntercomTicketState')
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
protected
|
|
11
|
+
|
|
12
|
+
def fetch_all
|
|
13
|
+
client.fetch_all('ticket_states')
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Two labels rather than one: `internal_label` is what the support team
|
|
17
|
+
# sees, `external_label` what the customer is shown. An operator reading a
|
|
18
|
+
# queue needs the first, and needs to know what the second says.
|
|
19
|
+
def serialize(ticket_state)
|
|
20
|
+
attrs = ticket_state.is_a?(Hash) ? ticket_state : {}
|
|
21
|
+
|
|
22
|
+
{ 'id' => stringify_id(attrs['id']),
|
|
23
|
+
'category' => attrs['category'],
|
|
24
|
+
'internal_label' => attrs['internal_label'],
|
|
25
|
+
'external_label' => attrs['external_label'],
|
|
26
|
+
'archived' => attrs['archived'] }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def define_schema
|
|
32
|
+
add_column('id', 'String', is_primary_key: true)
|
|
33
|
+
add_column('category', 'String')
|
|
34
|
+
add_column('internal_label', 'String')
|
|
35
|
+
add_column('external_label', 'String')
|
|
36
|
+
add_column('archived', 'Boolean')
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
module ForestAdminDatasourceIntercom
|
|
2
|
+
module Collections
|
|
3
|
+
# The ticket types the workspace defines. They are what makes a ticket's type
|
|
4
|
+
# readable, and they are also where the ticket attributes are declared --
|
|
5
|
+
# which is what the ticket collection reads them for.
|
|
6
|
+
class TicketType < FetchAllCollection
|
|
7
|
+
def initialize(datasource)
|
|
8
|
+
super(datasource, 'IntercomTicketType')
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
protected
|
|
12
|
+
|
|
13
|
+
def fetch_all
|
|
14
|
+
client.fetch_all('ticket_types')
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# `ticket_type_attributes` is deliberately left out: it is a nested list of
|
|
18
|
+
# attribute definitions, useful to the ticket collection and meaningless as
|
|
19
|
+
# a column. An attribute of the same name carries a different id from one
|
|
20
|
+
# type to the next (measured), which is exactly why the ticket collection
|
|
21
|
+
# has to read the definitions rather than assume them.
|
|
22
|
+
def serialize(ticket_type)
|
|
23
|
+
attrs = ticket_type.is_a?(Hash) ? ticket_type : {}
|
|
24
|
+
|
|
25
|
+
{ 'id' => stringify_id(attrs['id']),
|
|
26
|
+
'name' => attrs['name'],
|
|
27
|
+
'description' => attrs['description'],
|
|
28
|
+
'category' => attrs['category'],
|
|
29
|
+
'icon' => attrs['icon'],
|
|
30
|
+
'archived' => attrs['archived'] }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def define_schema
|
|
36
|
+
add_column('id', 'String', is_primary_key: true)
|
|
37
|
+
add_column('name', 'String')
|
|
38
|
+
add_column('description', 'String')
|
|
39
|
+
# The label (`Customer` / `Back-office` / `Tracker`), as on a ticket.
|
|
40
|
+
add_column('category', 'String')
|
|
41
|
+
add_column('icon', 'String')
|
|
42
|
+
add_column('archived', 'Boolean')
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
module ForestAdminDatasourceIntercom
|
|
2
|
+
module Collections
|
|
3
|
+
# The thread of a conversation or ticket. `part_type` is kept, and internal notes are included next to
|
|
4
|
+
# customer replies -- that is what a thread is on Intercom. Intercom keeps only the 500 most recent parts.
|
|
5
|
+
module Timeline
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def build_timeline(entity)
|
|
9
|
+
attrs = entity.is_a?(Hash) ? entity : {}
|
|
10
|
+
|
|
11
|
+
[opening_entry(attrs)].compact + (parts_of(attrs) || []).map { |part| part_entry(part) }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# What comes before the parts. A conversation opens on its `source`, which
|
|
15
|
+
# is not a part at all; a ticket opens on its first part like any other
|
|
16
|
+
# event, so there is nothing to prepend.
|
|
17
|
+
def opening_entry(_attrs) = nil
|
|
18
|
+
|
|
19
|
+
def part_entry(part)
|
|
20
|
+
attrs = part.is_a?(Hash) ? part : {}
|
|
21
|
+
|
|
22
|
+
entry(part_type: attrs['part_type'], created_at: attrs['created_at'], author: attrs['author'],
|
|
23
|
+
body: attrs['body'], attachments: attrs['attachments'])
|
|
24
|
+
.merge('id' => stringify_id(attrs['id']), 'redacted' => attrs['redacted'])
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def entry(part_type:, created_at:, author:, body:, attachments:)
|
|
28
|
+
writer = author.is_a?(Hash) ? author : {}
|
|
29
|
+
|
|
30
|
+
{ 'part_type' => part_type,
|
|
31
|
+
'created_at' => stamp(created_at),
|
|
32
|
+
'author_type' => writer['type'],
|
|
33
|
+
'author_name' => writer['name'],
|
|
34
|
+
'author_email' => writer['email'],
|
|
35
|
+
'body' => body,
|
|
36
|
+
'attachment_count' => Array(attachments).size }
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
module ForestAdminDatasourceIntercom
|
|
2
|
+
# Long by line count only: every option of the datasource is declared here
|
|
3
|
+
# with the reason it has the default it has, and next to the check that
|
|
4
|
+
# refuses the shapes it cannot mean. Splitting the checks off would put an
|
|
5
|
+
# option and its meaning in two files.
|
|
6
|
+
class Configuration # rubocop:disable Metrics/ClassLength
|
|
7
|
+
# A workspace is hosted in one region and answers in that region only. The
|
|
8
|
+
# host is therefore a configuration parameter rather than a constant:
|
|
9
|
+
# `api.intercom.io` does route to the right region, but a customer under
|
|
10
|
+
# GDPR wants its requests to reach the European host and nothing else.
|
|
11
|
+
REGION_HOSTS = {
|
|
12
|
+
us: 'https://api.intercom.io',
|
|
13
|
+
eu: 'https://api.eu.intercom.io',
|
|
14
|
+
au: 'https://api.au.intercom.io'
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
DEFAULT_REGION = :us
|
|
18
|
+
|
|
19
|
+
# Where a cleartext base_url reaches nobody else's network, so the bearer
|
|
20
|
+
# header crossing it in clear is not worth a word.
|
|
21
|
+
LOOPBACK_HOSTS = %w[localhost 127.0.0.1 ::1 [::1] 0.0.0.0].freeze
|
|
22
|
+
|
|
23
|
+
# Without an explicit version a request follows the workspace's own default,
|
|
24
|
+
# which an operator can change on Intercom's side -- and the payloads change
|
|
25
|
+
# shape under us. Pinned to what the spike ran against; 2.14 and 2.16 both
|
|
26
|
+
# answered, and the response echoes the version back, so `Client#me`
|
|
27
|
+
# verifies at boot that the pin was honoured.
|
|
28
|
+
DEFAULT_API_VERSION = '2.16'.freeze
|
|
29
|
+
|
|
30
|
+
# Reference lists are re-read on every page otherwise (four sequential round trips per ticket list);
|
|
31
|
+
# a minute bounds staleness. `0` disables the store. Records are never cached -- see `Cache`.
|
|
32
|
+
DEFAULT_REFERENCE_CACHE_TTL = 60
|
|
33
|
+
|
|
34
|
+
# A ticket list is ~7 requests to one host, so keep-alive saves a TLS handshake each. The pool is sized
|
|
35
|
+
# against the server's thread count (Puma default 5): a thread waiting >0.5s for a connection gets a
|
|
36
|
+
# non-retried timeout blamed on Intercom. `adapter: :net_http` opts out; an unloadable adapter is refused.
|
|
37
|
+
DEFAULT_ADAPTER = [:net_http_persistent, { pool_size: 25 }].freeze
|
|
38
|
+
|
|
39
|
+
# How many records a read by id fetches at once -- a related list of
|
|
40
|
+
# companies, a page of timelines -- Intercom answering those one request
|
|
41
|
+
# each. One by default: every extra read in flight holds a connection of
|
|
42
|
+
# the pool above, so widening this is the integrator's call, made against
|
|
43
|
+
# `pool_size` and the server's thread count together.
|
|
44
|
+
DEFAULT_CONCURRENT_READS = 1
|
|
45
|
+
|
|
46
|
+
attr_reader :access_token, :region, :base_url, :api_version, :open_timeout, :timeout,
|
|
47
|
+
:retry_policy, :rate_limiter, :boot_open_timeout, :boot_timeout, :boot_retry_policy,
|
|
48
|
+
:reference_cache_ttl, :adapter, :reference_cache, :concurrent_reads
|
|
49
|
+
|
|
50
|
+
# `rate_limiter: nil` leaves the 429 retry as the only rate-limit handling. The `boot_` trio governs
|
|
51
|
+
# reads made while the datasource is constructed (attribute introspection), during the Rails boot.
|
|
52
|
+
def initialize(access_token:, region: nil, base_url: nil, api_version: DEFAULT_API_VERSION,
|
|
53
|
+
open_timeout: 5, timeout: 30, retry_policy: RetryPolicy.new,
|
|
54
|
+
rate_limiter: RateLimiter.new, boot_open_timeout: 3, boot_timeout: 10,
|
|
55
|
+
boot_retry_policy: RetryPolicy.boot,
|
|
56
|
+
reference_cache_ttl: DEFAULT_REFERENCE_CACHE_TTL, adapter: nil,
|
|
57
|
+
concurrent_reads: DEFAULT_CONCURRENT_READS)
|
|
58
|
+
@access_token = access_token
|
|
59
|
+
@region = (region || DEFAULT_REGION).to_s.downcase.to_sym
|
|
60
|
+
@base_url = base_url
|
|
61
|
+
@api_version = api_version.to_s
|
|
62
|
+
@open_timeout = open_timeout
|
|
63
|
+
@timeout = timeout
|
|
64
|
+
@retry_policy = retry_policy
|
|
65
|
+
@rate_limiter = rate_limiter
|
|
66
|
+
@boot_open_timeout = boot_open_timeout
|
|
67
|
+
@boot_timeout = boot_timeout
|
|
68
|
+
@boot_retry_policy = boot_retry_policy
|
|
69
|
+
@reference_cache_ttl = reference_cache_ttl
|
|
70
|
+
@adapter = normalize_adapter(adapter)
|
|
71
|
+
@concurrent_reads = concurrent_reads
|
|
72
|
+
validate!
|
|
73
|
+
# One store per Configuration, hence per token, like the rate limiter:
|
|
74
|
+
# what it holds is a workspace's own lists, and two workspaces do not
|
|
75
|
+
# share them. Built here rather than lazily -- a memoized reader would
|
|
76
|
+
# hand two stores to the two threads that first raced for it, and one of
|
|
77
|
+
# them would then write into a store nothing else reads.
|
|
78
|
+
@reference_cache = Cache.new(ttl: @reference_cache_ttl)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# An explicit `base_url` wins over the region: it is what points the client
|
|
82
|
+
# at a mock server or an egress proxy, neither of which is a region.
|
|
83
|
+
def url
|
|
84
|
+
@url ||= (@base_url || REGION_HOSTS.fetch(@region)).chomp('/')
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# The url with any credentials in it masked. `URI` accepts them --
|
|
88
|
+
# `https://user:pass@proxy/...` -- and the refusal that names such a url
|
|
89
|
+
# would otherwise print a second secret exactly where this class keeps the
|
|
90
|
+
# first one from going. A query string is masked whole for the same
|
|
91
|
+
# reason: it is where a gateway takes its key. Read by `Client#inspect`
|
|
92
|
+
# too, which prints the same url.
|
|
93
|
+
def redacted_url
|
|
94
|
+
@redacted_url ||= url.sub(%r{\A([a-zA-Z][\w+.-]*://)[^/@]*@}, '\1[FILTERED]@')
|
|
95
|
+
.sub(/\?.*\z/m, '?[FILTERED]')
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# `access_token` is a bearer credential, and nothing prints a Configuration
|
|
99
|
+
# on purpose: what reaches an `inspect` is a Rails error page, or a
|
|
100
|
+
# `logger.debug` of something holding one. The default would put the token
|
|
101
|
+
# in clear there. `Client` and `Datasource` mask their own for the same
|
|
102
|
+
# reason -- together they cut every path from an object this package hands
|
|
103
|
+
# out to the credential.
|
|
104
|
+
def inspect
|
|
105
|
+
"#<#{self.class.name} url=#{redacted_url.inspect} api_version=#{@api_version.inspect} " \
|
|
106
|
+
'access_token=[FILTERED]>'
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
def validate!
|
|
112
|
+
raise ConfigurationError, 'ForestAdminDatasourceIntercom missing required config: access_token' if
|
|
113
|
+
blank?(@access_token)
|
|
114
|
+
|
|
115
|
+
validate_region!
|
|
116
|
+
validate_base_url!
|
|
117
|
+
raise ConfigurationError, 'ForestAdminDatasourceIntercom api_version cannot be empty' if blank?(@api_version)
|
|
118
|
+
|
|
119
|
+
validate_reference_cache_ttl!
|
|
120
|
+
validate_concurrent_reads!
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def validate_concurrent_reads!
|
|
124
|
+
return if @concurrent_reads.is_a?(Integer) && @concurrent_reads.positive?
|
|
125
|
+
|
|
126
|
+
raise ConfigurationError,
|
|
127
|
+
'ForestAdminDatasourceIntercom concurrent_reads must be a whole number of reads, one or more, got ' \
|
|
128
|
+
"#{@concurrent_reads.inspect}"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Refuses nil, non-numeric strings and Infinity rather than coercing them into a silent 0 (cache off)
|
|
132
|
+
# or a never-expiring cache.
|
|
133
|
+
def validate_reference_cache_ttl!
|
|
134
|
+
return if @reference_cache_ttl.is_a?(Numeric) && @reference_cache_ttl.finite? &&
|
|
135
|
+
!@reference_cache_ttl.negative?
|
|
136
|
+
|
|
137
|
+
raise ConfigurationError,
|
|
138
|
+
'ForestAdminDatasourceIntercom reference_cache_ttl must be a finite number of seconds, zero or ' \
|
|
139
|
+
"more, got #{@reference_cache_ttl.inspect}"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Faraday's adapter registry is keyed by symbol: a string name (as YAML hands over) silently lost
|
|
143
|
+
# keep-alive. Whether the name resolves is left to the client, which refuses one this process cannot load.
|
|
144
|
+
def normalize_adapter(adapter)
|
|
145
|
+
return nil if adapter.nil?
|
|
146
|
+
|
|
147
|
+
name, options = adapter.is_a?(Array) ? adapter : [adapter, nil]
|
|
148
|
+
refuse_adapter!(adapter) unless name.respond_to?(:to_sym)
|
|
149
|
+
refuse_adapter!(adapter) unless options.nil? || namable_hash?(options)
|
|
150
|
+
|
|
151
|
+
options.nil? ? name.to_sym : [name.to_sym, options.to_h { |key, value| [key.to_sym, value] }]
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def namable_hash?(options)
|
|
155
|
+
options.is_a?(Hash) && options.keys.all? { |key| key.respond_to?(:to_sym) }
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def refuse_adapter!(adapter)
|
|
159
|
+
raise ConfigurationError,
|
|
160
|
+
'ForestAdminDatasourceIntercom adapter must be an adapter name or a [name, options] pair, got ' \
|
|
161
|
+
"#{adapter.inspect}"
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def validate_region!
|
|
165
|
+
return if @base_url || REGION_HOSTS.key?(@region)
|
|
166
|
+
|
|
167
|
+
raise ConfigurationError,
|
|
168
|
+
"ForestAdminDatasourceIntercom unknown region #{@region.inspect}: " \
|
|
169
|
+
"expected one of #{REGION_HOSTS.keys.map(&:inspect).join(", ")}, or an explicit base_url."
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# A base url that is not absolute makes Faraday resolve every path against
|
|
173
|
+
# the process's working directory instead of Intercom, which surfaces much
|
|
174
|
+
# later as a connection failure naming nothing.
|
|
175
|
+
def validate_base_url!
|
|
176
|
+
return if @base_url.nil?
|
|
177
|
+
|
|
178
|
+
uri = URI.parse(@base_url)
|
|
179
|
+
refuse_credentials! if uri.userinfo
|
|
180
|
+
return warn_cleartext!(uri) if uri.is_a?(URI::HTTP) && !blank?(uri.host)
|
|
181
|
+
|
|
182
|
+
raise ConfigurationError,
|
|
183
|
+
"ForestAdminDatasourceIntercom base_url must be an absolute http(s) url, got #{redacted_url.inspect}"
|
|
184
|
+
rescue URI::InvalidURIError
|
|
185
|
+
# Without the parser's error, whose message quotes the url unredacted.
|
|
186
|
+
raise ConfigurationError,
|
|
187
|
+
"ForestAdminDatasourceIntercom base_url is not a valid url: #{redacted_url.inspect}", cause: nil
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Faraday turns credentials in the url into a Basic `Authorization`
|
|
191
|
+
# header, which the Intercom bearer token then replaces: they would be
|
|
192
|
+
# dropped on every request without a word. An egress proxy takes its
|
|
193
|
+
# credentials from `HTTPS_PROXY`, which Faraday sends as
|
|
194
|
+
# `Proxy-Authorization` instead.
|
|
195
|
+
def refuse_credentials!
|
|
196
|
+
raise ConfigurationError,
|
|
197
|
+
"ForestAdminDatasourceIntercom base_url #{redacted_url.inspect} carries credentials, which cannot " \
|
|
198
|
+
'travel: the Authorization header carries the Intercom access token. For a credentialed egress ' \
|
|
199
|
+
'proxy, set HTTPS_PROXY=https://user:password@proxy:port instead.'
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# `URI::HTTPS` is a `URI::HTTP`, so plain http passes above -- and it is
|
|
203
|
+
# worth passing: a mock server in a test suite is one, and that is half of
|
|
204
|
+
# what `base_url` is for. What it costs is `Authorization: Bearer <token>`
|
|
205
|
+
# travelling in clear to whatever sits at the other end, and that token
|
|
206
|
+
# reads the whole workspace. Named rather than allowed in silence; a
|
|
207
|
+
# loopback host is nobody else's network, so it says nothing there.
|
|
208
|
+
def warn_cleartext!(uri)
|
|
209
|
+
return if uri.scheme == 'https' || loopback?(uri.host)
|
|
210
|
+
|
|
211
|
+
ForestAdminDatasourceIntercom.logger.warn(
|
|
212
|
+
"[forest_admin_datasource_intercom] base_url #{redacted_url.inspect} is not https, and every request " \
|
|
213
|
+
'carries the Intercom access token as a bearer header. Anything on the path can read it and use it ' \
|
|
214
|
+
'against the workspace. Use https, or terminate TLS before the network this crosses.'
|
|
215
|
+
)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def loopback?(host)
|
|
219
|
+
LOOPBACK_HOSTS.include?(host.to_s.downcase) || host.to_s.downcase.end_with?('.localhost')
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def blank?(value)
|
|
223
|
+
value.nil? || value.to_s.strip.empty?
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
module ForestAdminDatasourceIntercom
|
|
2
|
+
class Datasource < ForestAdminDatasourceToolkit::Datasource
|
|
3
|
+
attr_reader :client, :configuration
|
|
4
|
+
|
|
5
|
+
def initialize(access_token:, **options)
|
|
6
|
+
super()
|
|
7
|
+
@configuration = Configuration.new(access_token: access_token, **options)
|
|
8
|
+
@client = Client.new(@configuration)
|
|
9
|
+
|
|
10
|
+
verify_api_version
|
|
11
|
+
register_collections
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# The datasource is what a Rails error page or a `logger.debug` is likeliest
|
|
15
|
+
# to print, and it holds the client whose connections carry the access token.
|
|
16
|
+
# Every collection will reach that token the same way, through the
|
|
17
|
+
# `@datasource` the toolkit's Collection keeps, so cutting the chain here
|
|
18
|
+
# covers them too -- and spares the recursive dump the default `inspect`
|
|
19
|
+
# walks into, a datasource and its collections pointing at each other.
|
|
20
|
+
def inspect
|
|
21
|
+
"#<#{self.class.name} collections=#{collections.keys.inspect}>"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
# The reference collections first: they are what turns an assignee id into a
|
|
27
|
+
# teammate and a state id into a label. No request is made here -- each
|
|
28
|
+
# collection reads its endpoint when it is listed, so a datasource boots
|
|
29
|
+
# whatever Intercom is doing, and a workspace the token cannot read costs
|
|
30
|
+
# rows rather than the agent.
|
|
31
|
+
def register_collections
|
|
32
|
+
add_collection(Collections::Admin.new(self))
|
|
33
|
+
add_collection(Collections::Team.new(self))
|
|
34
|
+
# The join Intercom does not expose: without it the membership of a team is
|
|
35
|
+
# an array of ids on either side, since a many-to-many needs a collection
|
|
36
|
+
# to travel through.
|
|
37
|
+
add_collection(Collections::TeamMembership.new(self))
|
|
38
|
+
add_collection(Collections::TicketType.new(self))
|
|
39
|
+
add_collection(Collections::TicketState.new(self))
|
|
40
|
+
# Contacts and Companies before the collections that point at them, so a
|
|
41
|
+
# relation is declared next to a target the datasource already holds.
|
|
42
|
+
# Each carries the custom attributes its workspace defines, read at boot:
|
|
43
|
+
# they cannot be discovered from a payload, a contact carrying the values
|
|
44
|
+
# of the attributes it happens to have been given.
|
|
45
|
+
add_collection(Collections::Contact.new(self, attributes: model_attributes('contact')))
|
|
46
|
+
add_collection(Collections::Company.new(self, attributes: model_attributes('company')))
|
|
47
|
+
add_collection(Collections::Conversation.new(self))
|
|
48
|
+
# The attributes a workspace defines on its ticket types, which are
|
|
49
|
+
# columns of the Tickets collection and cannot be discovered from a ticket
|
|
50
|
+
# payload either -- a ticket carries the values of its own type only.
|
|
51
|
+
add_collection(Collections::Ticket.new(self, attributes: ticket_attributes))
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Intercom silently serves the workspace's default API version when the pin is not honoured;
|
|
55
|
+
# `Client#me` checks the echoed version. A token that cannot reach `/me` costs the check, not the boot.
|
|
56
|
+
def verify_api_version
|
|
57
|
+
@client.me(boot: true)
|
|
58
|
+
rescue APIError => e
|
|
59
|
+
ForestAdminDatasourceIntercom.logger.warn(
|
|
60
|
+
"[forest_admin_datasource_intercom] could not read /me at boot (HTTP #{e.status || "-"}); the API " \
|
|
61
|
+
'version Intercom serves was not checked against the pinned one, and a workspace serving another one ' \
|
|
62
|
+
'answers payloads of another shape.'
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# The three attribute reads of the boot, alongside the version check above.
|
|
67
|
+
# Each degrades to no attribute column rather than to a failed boot: a
|
|
68
|
+
# token missing a permission costs the columns it could not read, never
|
|
69
|
+
# the agent.
|
|
70
|
+
def ticket_attributes
|
|
71
|
+
Schema::TicketAttributesIntrospector.new(@client).attributes
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def model_attributes(model)
|
|
75
|
+
Schema::DataAttributesIntrospector.new(@client, model: model).attributes
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|