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,77 @@
|
|
|
1
|
+
module ForestAdminDatasourceIntercom
|
|
2
|
+
module Collections
|
|
3
|
+
# Intercom nests only contact ids, so `contact_name` costs one bulk read per page, never per row.
|
|
4
|
+
module ContactIdentity
|
|
5
|
+
COLUMNS = %w[contact_name].freeze
|
|
6
|
+
|
|
7
|
+
# Must match the `contact` relation's chunking so `Client#with_read_scope` dedups the two
|
|
8
|
+
# reads into one request (guarded by `ticket_list_budget_spec`).
|
|
9
|
+
CONTACT_CHUNK = Contact::IDS_PER_READ
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def define_contact_columns
|
|
14
|
+
add_column('contact_id', 'String')
|
|
15
|
+
add_column('contact_count', 'Number')
|
|
16
|
+
add_column('contact_name', 'String')
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# A group conversation, or a ticket opened for several people, has more
|
|
20
|
+
# than one contact: the row names the first and counts them, rather than
|
|
21
|
+
# presenting one of several as the one. The `contact` relation resolves
|
|
22
|
+
# that same first contact, so the column and the relation cannot disagree;
|
|
23
|
+
# the others are reached through the contact's own conversations.
|
|
24
|
+
def contact_columns_for(attrs)
|
|
25
|
+
ids = nested_list(attrs['contacts'], 'contacts').filter_map { |contact| stringify_id(contact['id']) }
|
|
26
|
+
|
|
27
|
+
{ 'contact_id' => ids.first, 'contact_count' => ids.size,
|
|
28
|
+
# Filled by the bulk read below, and left nil when the projection did
|
|
29
|
+
# not ask for it.
|
|
30
|
+
'contact_name' => nil }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def first_contact_id(record)
|
|
34
|
+
contact = nested_list((record || {})['contacts'], 'contacts').first
|
|
35
|
+
contact.is_a?(Hash) ? stringify_id(contact['id']) : nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The Contacts endpoint as the table spells it, rather than a path written
|
|
39
|
+
# a second time here: this is the same `/contacts/search` the Contacts
|
|
40
|
+
# collection reads itself through, and a table that renamed it would
|
|
41
|
+
# otherwise leave this one behind.
|
|
42
|
+
def contact_search_path
|
|
43
|
+
@contact_search_path ||= Query::SearchFields.fetch('contacts').path
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def embed_contact_identity(records, rows, projection)
|
|
47
|
+
return unless any_column_asked?(projection, COLUMNS)
|
|
48
|
+
|
|
49
|
+
identities = contact_identities(records)
|
|
50
|
+
records.each_with_index do |record, index|
|
|
51
|
+
identity = identities[first_contact_id(record)] || {}
|
|
52
|
+
rows[index]['contact_name'] = identity['name'] if rows[index].key?('contact_name')
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# A failure costs the column and nothing else: an identity that could not
|
|
57
|
+
# be read is not a page that could not be served.
|
|
58
|
+
def contact_identities(records)
|
|
59
|
+
ids = records.filter_map { |record| first_contact_id(record) }.uniq
|
|
60
|
+
return {} if ids.empty?
|
|
61
|
+
|
|
62
|
+
ids.each_slice(CONTACT_CHUNK).with_object({}) do |chunk, indexed|
|
|
63
|
+
page = client.search_page(contact_search_path, per_page: chunk.size,
|
|
64
|
+
query: { 'field' => 'id', 'operator' => 'IN',
|
|
65
|
+
'value' => chunk })
|
|
66
|
+
page.records.each { |contact| indexed[contact['id'].to_s] = contact }
|
|
67
|
+
end
|
|
68
|
+
rescue APIError => e
|
|
69
|
+
ForestAdminDatasourceIntercom.logger.warn(
|
|
70
|
+
"[forest_admin_datasource_intercom] #{name} could not read the contacts of this page (HTTP " \
|
|
71
|
+
"#{e.status || "-"}); the name column is left empty for it."
|
|
72
|
+
)
|
|
73
|
+
{}
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
module ForestAdminDatasourceIntercom
|
|
2
|
+
module Collections
|
|
3
|
+
class Conversation < CursorCollection
|
|
4
|
+
# One Intercom conversation flattened into the row the schema declares.
|
|
5
|
+
# Nothing here reads a sub-resource: every value comes from the payload the
|
|
6
|
+
# listing already returned.
|
|
7
|
+
module Serializer
|
|
8
|
+
protected
|
|
9
|
+
|
|
10
|
+
def serialize(conversation)
|
|
11
|
+
attrs = conversation.is_a?(Hash) ? conversation : {}
|
|
12
|
+
|
|
13
|
+
native(attrs)
|
|
14
|
+
.merge(contact_columns_for(attrs))
|
|
15
|
+
.merge(source_of(attrs['source']))
|
|
16
|
+
.merge(statistics_of(attrs['statistics']))
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def native(attrs)
|
|
22
|
+
company = attrs['company'].is_a?(Hash) ? attrs['company'] : {}
|
|
23
|
+
|
|
24
|
+
{
|
|
25
|
+
'id' => stringify_id(attrs['id']),
|
|
26
|
+
'title' => attrs['title'],
|
|
27
|
+
'state' => attrs['state'],
|
|
28
|
+
'priority' => attrs['priority'],
|
|
29
|
+
'open' => attrs['open'],
|
|
30
|
+
'read' => attrs['read'],
|
|
31
|
+
'created_at' => stamp(attrs['created_at']),
|
|
32
|
+
'updated_at' => stamp(attrs['updated_at']),
|
|
33
|
+
'waiting_since' => stamp(attrs['waiting_since']),
|
|
34
|
+
'snoozed_until' => stamp(attrs['snoozed_until']),
|
|
35
|
+
'admin_assignee_id' => stringify_id(attrs['admin_assignee_id']),
|
|
36
|
+
'team_assignee_id' => stringify_id(attrs['team_assignee_id']),
|
|
37
|
+
'company_id' => stringify_id(company['id']),
|
|
38
|
+
'company_name' => company['name'],
|
|
39
|
+
'tag_names' => nested_list(attrs['tags'], 'tags').filter_map { |tag| tag['name'] if tag.is_a?(Hash) },
|
|
40
|
+
'ai_agent_participated' => attrs['ai_agent_participated']
|
|
41
|
+
}
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def source_of(source)
|
|
45
|
+
attrs = source.is_a?(Hash) ? source : {}
|
|
46
|
+
author = attrs['author'].is_a?(Hash) ? attrs['author'] : {}
|
|
47
|
+
|
|
48
|
+
{ 'source_type' => attrs['type'],
|
|
49
|
+
'source_subject' => attrs['subject'],
|
|
50
|
+
# Plaintext, because `display_as=plaintext` rides on every read: the
|
|
51
|
+
# bodies are HTML written by end customers.
|
|
52
|
+
'source_body' => attrs['body'],
|
|
53
|
+
'source_author_name' => author['name'],
|
|
54
|
+
'source_author_email' => author['email'],
|
|
55
|
+
'source_delivered_as' => attrs['delivered_as'] }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# `statistics` is null on a conversation Intercom has computed nothing
|
|
59
|
+
# for yet; every column then reads as absent rather than as zero.
|
|
60
|
+
def statistics_of(statistics)
|
|
61
|
+
attrs = statistics.is_a?(Hash) ? statistics : {}
|
|
62
|
+
|
|
63
|
+
{ 'closed_at' => stamp(attrs['last_close_at']),
|
|
64
|
+
'first_closed_at' => stamp(attrs['first_close_at']),
|
|
65
|
+
'closed_by_id' => stringify_id(attrs['last_closed_by_id']),
|
|
66
|
+
'first_contact_reply_at' => stamp(attrs['first_contact_reply_at']),
|
|
67
|
+
'last_contact_reply_at' => stamp(attrs['last_contact_reply_at']),
|
|
68
|
+
'last_admin_reply_at' => stamp(attrs['last_admin_reply_at']),
|
|
69
|
+
'reopen_count' => attrs['count_reopens'],
|
|
70
|
+
'part_count' => attrs['count_conversation_parts'] }
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
module ForestAdminDatasourceIntercom
|
|
2
|
+
module Collections
|
|
3
|
+
class Conversation < CursorCollection
|
|
4
|
+
# What a conversation adds to the shared thread: where its parts live, and
|
|
5
|
+
# the entry that opens it.
|
|
6
|
+
#
|
|
7
|
+
# The opening message lives in `source`, not in the parts -- a timeline
|
|
8
|
+
# built from the parts alone opens on the first reply and loses what the
|
|
9
|
+
# customer actually asked.
|
|
10
|
+
module Timeline
|
|
11
|
+
# The pseudo type of the opening entry. Not an Intercom part type: it is
|
|
12
|
+
# the source, and calling it `comment` would make it indistinguishable
|
|
13
|
+
# from the replies that follow.
|
|
14
|
+
SOURCE_PART_TYPE = 'conversation_started'.freeze
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
# nil rather than an empty list when the payload carries no parts at all:
|
|
19
|
+
# a listing response has none, and reading that as "this conversation is
|
|
20
|
+
# empty" is exactly the answer that looks complete without being it.
|
|
21
|
+
def parts_of(conversation)
|
|
22
|
+
container = (conversation || {})['conversation_parts']
|
|
23
|
+
return nil unless container.is_a?(Hash)
|
|
24
|
+
|
|
25
|
+
parts = container['conversation_parts']
|
|
26
|
+
parts.is_a?(Array) ? parts : nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def opening_entry(attrs)
|
|
30
|
+
source = attrs['source']
|
|
31
|
+
return nil unless source.is_a?(Hash)
|
|
32
|
+
|
|
33
|
+
entry(part_type: SOURCE_PART_TYPE, created_at: attrs['created_at'], author: source['author'],
|
|
34
|
+
body: source['body'], attachments: source['attachments'])
|
|
35
|
+
.merge('id' => stringify_id(source['id']))
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
module ForestAdminDatasourceIntercom
|
|
2
|
+
module Collections
|
|
3
|
+
# Records come back under `conversations`, not `data`. Every read sends `display_as=plaintext`: bodies
|
|
4
|
+
# are customer-written HTML, unsafe to render inside Forest (R10).
|
|
5
|
+
# Long by line count only: most of it declares the columns, one call each.
|
|
6
|
+
class Conversation < CursorCollection
|
|
7
|
+
include ContactIdentity
|
|
8
|
+
include Conversation::Serializer
|
|
9
|
+
# The shared thread, then the hooks this collection puts over it.
|
|
10
|
+
include Collections::Timeline
|
|
11
|
+
include Conversation::Timeline
|
|
12
|
+
|
|
13
|
+
# How many conversations of one page may have their timeline read. The
|
|
14
|
+
# parts are absent from the listing response -- Intercom returns them only
|
|
15
|
+
# when retrieving a single conversation -- so a timeline asked for in a
|
|
16
|
+
# list view costs one request per row. Bounded rather than turned into a
|
|
17
|
+
# page the operator waits half a minute for; rows past the cap are left at
|
|
18
|
+
# nil, which reads as "unknown", never as "this conversation is empty".
|
|
19
|
+
MAX_TIMELINE_READS = 10
|
|
20
|
+
|
|
21
|
+
def initialize(datasource)
|
|
22
|
+
super(datasource, 'IntercomConversation')
|
|
23
|
+
# The one collection of this datasource Intercom matches text on: `~` on
|
|
24
|
+
# `source.body`, which is the message that opened the conversation.
|
|
25
|
+
enable_search
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
protected
|
|
29
|
+
|
|
30
|
+
def list_endpoint = 'conversations'
|
|
31
|
+
def list_key = 'conversations'
|
|
32
|
+
def searchable = 'conversations'
|
|
33
|
+
def search_column = 'source_body'
|
|
34
|
+
|
|
35
|
+
# Sent on the search too, where Intercom does not document it: the bodies
|
|
36
|
+
# are HTML written by end customers (R10), and a parameter it ignores costs
|
|
37
|
+
# a query string while the one it honours saves every filtered row from
|
|
38
|
+
# coming back as markup.
|
|
39
|
+
def read_params = { 'display_as' => 'plaintext' }
|
|
40
|
+
|
|
41
|
+
# The contact identity and the timeline, each read only when the projection
|
|
42
|
+
# names it: neither is on the conversation payload, and a page that never
|
|
43
|
+
# asked for them must not pay for them.
|
|
44
|
+
def enrich(records, rows, projection)
|
|
45
|
+
embed_contact_identity(records, rows, projection)
|
|
46
|
+
embed_timeline(records, rows, projection)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def define_schema
|
|
52
|
+
add_column('id', 'String', is_primary_key: true)
|
|
53
|
+
add_column('title', 'String')
|
|
54
|
+
# Left plain strings rather than enums: the values a workspace really
|
|
55
|
+
# serves are worth measuring before the interface offers them as a
|
|
56
|
+
# closed list, and nothing filters on them in this lot anyway.
|
|
57
|
+
add_column('state', 'String')
|
|
58
|
+
add_column('priority', 'String')
|
|
59
|
+
add_column('open', 'Boolean')
|
|
60
|
+
add_column('read', 'Boolean')
|
|
61
|
+
add_column('created_at', 'Date')
|
|
62
|
+
add_column('updated_at', 'Date')
|
|
63
|
+
add_column('waiting_since', 'Date')
|
|
64
|
+
add_column('snoozed_until', 'Date')
|
|
65
|
+
define_assignment_columns
|
|
66
|
+
define_contact_columns
|
|
67
|
+
define_source_columns
|
|
68
|
+
define_statistics_columns
|
|
69
|
+
add_column('tag_names', 'Json')
|
|
70
|
+
add_column('ai_agent_participated', 'Boolean')
|
|
71
|
+
add_column('timeline', 'Json')
|
|
72
|
+
define_relations
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# /conversations/search filters on all three keys and on a contact id (measured), so `contact:email` is
|
|
76
|
+
# resolved through /contacts/search. No company relation: Intercom filters no company field here. A group
|
|
77
|
+
# conversation names its first contact.
|
|
78
|
+
def define_relations
|
|
79
|
+
add_many_to_one('admin_assignee', foreign_collection: 'IntercomAdmin', foreign_key: 'admin_assignee_id')
|
|
80
|
+
add_many_to_one('team_assignee', foreign_collection: 'IntercomTeam', foreign_key: 'team_assignee_id')
|
|
81
|
+
add_many_to_one('closed_by', foreign_collection: 'IntercomAdmin', foreign_key: 'closed_by_id')
|
|
82
|
+
add_many_to_one('contact', foreign_collection: 'IntercomContact', foreign_key: 'contact_id')
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Who the conversation sits with, and which account it belongs to. The
|
|
86
|
+
# conversation carries its company as a whole object, so the account name
|
|
87
|
+
# is free here -- unlike on a ticket, which carries the id alone.
|
|
88
|
+
def define_assignment_columns
|
|
89
|
+
add_column('admin_assignee_id', 'String')
|
|
90
|
+
add_column('team_assignee_id', 'String')
|
|
91
|
+
add_column('company_id', 'String')
|
|
92
|
+
add_column('company_name', 'String')
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# The message that opened the conversation lives in `source`, not in the
|
|
96
|
+
# parts. A timeline built from the parts alone loses it, which is the one
|
|
97
|
+
# message nobody opens a conversation without wanting to read.
|
|
98
|
+
def define_source_columns
|
|
99
|
+
add_column('source_type', 'String')
|
|
100
|
+
add_column('source_subject', 'String')
|
|
101
|
+
add_column('source_body', 'String')
|
|
102
|
+
add_column('source_author_name', 'String')
|
|
103
|
+
add_column('source_author_email', 'String')
|
|
104
|
+
add_column('source_delivered_as', 'String')
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Intercom keeps the lifecycle of a conversation in `statistics`, which is
|
|
108
|
+
# where the closure date and the reply timestamps come from. Flattened onto
|
|
109
|
+
# the row: they cost nothing, they are exact, and they are what an ops lead
|
|
110
|
+
# reads a queue for.
|
|
111
|
+
def define_statistics_columns
|
|
112
|
+
add_column('closed_at', 'Date')
|
|
113
|
+
add_column('first_closed_at', 'Date')
|
|
114
|
+
add_column('closed_by_id', 'String')
|
|
115
|
+
add_column('first_contact_reply_at', 'Date')
|
|
116
|
+
add_column('last_contact_reply_at', 'Date')
|
|
117
|
+
add_column('last_admin_reply_at', 'Date')
|
|
118
|
+
add_column('reopen_count', 'Number')
|
|
119
|
+
add_column('part_count', 'Number')
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# A record read through the record endpoint already carries its parts, so
|
|
123
|
+
# its timeline is free; one read from the listing does not, and pays a
|
|
124
|
+
# request. Rows past the cap keep the nil the projection put there.
|
|
125
|
+
def embed_timeline(records, rows, projection)
|
|
126
|
+
return unless column_asked?(projection, 'timeline')
|
|
127
|
+
|
|
128
|
+
unread = []
|
|
129
|
+
records.each_with_index do |record, index|
|
|
130
|
+
if parts_of(record)
|
|
131
|
+
rows[index]['timeline'] = build_timeline(record)
|
|
132
|
+
else
|
|
133
|
+
unread << index
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
read = unread.first(MAX_TIMELINE_READS)
|
|
137
|
+
details = client.fetch_records(list_endpoint, read.map { |index| records[index]['id'] }, params: read_params)
|
|
138
|
+
read.zip(details) { |index, detail| rows[index]['timeline'] = detail && build_timeline(detail) }
|
|
139
|
+
|
|
140
|
+
missing = unread.size - read.size
|
|
141
|
+
warn_truncated_timelines(missing) if missing.positive?
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def warn_truncated_timelines(missing)
|
|
145
|
+
ForestAdminDatasourceIntercom.logger.warn(
|
|
146
|
+
"[forest_admin_datasource_intercom] #{name} left the timeline of #{missing} row(s) unread: Intercom " \
|
|
147
|
+
'returns the parts only when retrieving one conversation, so a list view pays a request per row and ' \
|
|
148
|
+
"this reads at most #{MAX_TIMELINE_READS}. Those rows show no timeline rather than an empty one."
|
|
149
|
+
)
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|