forest_admin_datasource_pylon 1.46.4 → 1.48.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 +4 -4
- data/README.md +55 -0
- data/lib/forest_admin_datasource_pylon/client/writes.rb +15 -0
- data/lib/forest_admin_datasource_pylon/client.rb +23 -12
- data/lib/forest_admin_datasource_pylon/collections/issue/messages_embedder.rb +14 -2
- data/lib/forest_admin_datasource_pylon/collections/issue.rb +4 -1
- data/lib/forest_admin_datasource_pylon/plugins/reply_to_issue/form_builder.rb +88 -0
- data/lib/forest_admin_datasource_pylon/plugins/reply_to_issue/operator_lookup.rb +47 -0
- data/lib/forest_admin_datasource_pylon/plugins/reply_to_issue/reply_target.rb +86 -0
- data/lib/forest_admin_datasource_pylon/plugins/reply_to_issue/thread_lookup.rb +37 -0
- data/lib/forest_admin_datasource_pylon/plugins/reply_to_issue.rb +142 -0
- data/lib/forest_admin_datasource_pylon/rate_limits.rb +2 -0
- data/lib/forest_admin_datasource_pylon/version.rb +1 -1
- metadata +10 -8
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 33487254f529083347aab6801d80be282d51eae68e4e1c9206c570930bed7949
|
|
4
|
+
data.tar.gz: 14768f83aa896b2340d46a514cb12b64ed8052b8f59cdb71e7fbb2e7c7707700
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 141c4d7b99d1d81bde81cd0959b29a1541102f06b6b5dc68ae11d34ad4a86e87ab962714c74d580503eb919447b6e0cc7fc33f4becc32f3a7d71ef268c353809
|
|
7
|
+
data.tar.gz: 1608a31298e52286cc8741a3acbab9eecb9565718c08164c4a1cf1d9b65cab89ee728fc76d517307167203ab9d3473cdb458221d985a925bbfb5664e22d284e4
|
data/README.md
CHANGED
|
@@ -169,6 +169,61 @@ The message body is HTML the operator writes and Pylon delivers to the requester
|
|
|
169
169
|
requester address is a free-text field. Restrict both actions to the roles that should be able to
|
|
170
170
|
send mail on your organization's behalf.
|
|
171
171
|
|
|
172
|
+
### `ReplyToIssue`
|
|
173
|
+
|
|
174
|
+
Answers one issue on the channel the customer used — a Slack issue gets a Slack reply, an email
|
|
175
|
+
issue an email — or posts an internal note on it. Single scope only.
|
|
176
|
+
|
|
177
|
+
```ruby
|
|
178
|
+
@agent.collection :Ticket do |collection|
|
|
179
|
+
collection.use(ForestAdminDatasourcePylon::Plugins::ReplyToIssue, {
|
|
180
|
+
datasource: pylon_datasource, # required
|
|
181
|
+
issue_id_field: 'pylon_issue_id', # omit when the action sits on PylonIssue itself
|
|
182
|
+
action_name: 'Reply to Pylon issue', # default
|
|
183
|
+
allow_internal_note: true, # default
|
|
184
|
+
support_emails: ['support@example.com'],
|
|
185
|
+
post_as_operator: false # default
|
|
186
|
+
})
|
|
187
|
+
end
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
**Which channel.** Pylon delivers a reply into the conversation of the message it names, so the
|
|
191
|
+
action picks that message: the channel is the one of the first customer-visible message, and the
|
|
192
|
+
reply answers the latest customer-visible message on that channel. The form shows the channel
|
|
193
|
+
read-only. An issue with no customer-visible message — one created as an internal note, for
|
|
194
|
+
instance — cannot get a public reply; only the internal note is offered.
|
|
195
|
+
|
|
196
|
+
**Email recipients.** Pylon does not infer them, so the form pre-fills To and CC from the latest
|
|
197
|
+
email of the conversation. When the customer wrote it, To is its sender only: the addresses the
|
|
198
|
+
customer wrote to include your support inbox. When the team wrote it, To is whom it went to. The
|
|
199
|
+
`support_emails` and the addresses the team wrote from in the thread are always left out, from what
|
|
200
|
+
the operator types as well, and the result message names any typed address left out. Set
|
|
201
|
+
`support_emails`: before the team's first email in a thread, it is the only way to know your own
|
|
202
|
+
addresses. A reply with no To is refused.
|
|
203
|
+
|
|
204
|
+
**When Pylon does not confirm.** A 4xx is shown with Pylon's own reason. Any other failure, a
|
|
205
|
+
timeout included, may come after Pylon delivered the message, so the action says so and asks to
|
|
206
|
+
check the issue before sending again.
|
|
207
|
+
|
|
208
|
+
**Who signs.** By default the account owning the API token signs every reply, and the success
|
|
209
|
+
message says so. With `post_as_operator: true`, a public reply is signed by the active Pylon user
|
|
210
|
+
whose email matches the Forest user's, primary or secondary address. With no such user, or several,
|
|
211
|
+
the reply is refused and nothing is sent: it never falls back to the token. Internal notes are
|
|
212
|
+
always signed by the token.
|
|
213
|
+
|
|
214
|
+
Three things to know before granting it.
|
|
215
|
+
|
|
216
|
+
- **The column is the authority.** With `issue_id_field`, as with `CloseIssue`, an operator who can
|
|
217
|
+
write that column can reply to any Pylon issue, on your organization's behalf.
|
|
218
|
+
- **`post_as_operator` trusts emails.** The token can sign as any agent of the workspace; the only
|
|
219
|
+
thing tying a Forest user to a Pylon user is that they share an email. Keep both user lists
|
|
220
|
+
consistent, and restrict the action to the roles that should answer customers.
|
|
221
|
+
- **The thread is read on every form render**, one `GET /issues/{id}/messages` (120 a minute).
|
|
222
|
+
A submit costs one or two more: the agent renders the form again before running the action, and
|
|
223
|
+
a public reply reads the thread once more to answer its latest state, plus one `GET /users` with
|
|
224
|
+
`post_as_operator`. An unreadable thread is shown as an error, never as an issue without customer
|
|
225
|
+
message: both reply types stay offered, and an internal note does not depend on the thread.
|
|
226
|
+
|
|
172
227
|
## Development
|
|
173
228
|
|
|
174
229
|
```bash
|
|
@@ -29,8 +29,23 @@ module ForestAdminDatasourcePylon
|
|
|
29
29
|
|
|
30
30
|
def update_user(id, attributes) = patch_resource('users', id, attributes)
|
|
31
31
|
|
|
32
|
+
# `message_id` picks the customer conversation the reply is delivered to.
|
|
33
|
+
def reply_to_issue(id, attributes) = post_message(issue_path(id, 'reply'), attributes)
|
|
34
|
+
def create_issue_note(id, attributes) = post_message(issue_path(id, 'note'), attributes)
|
|
35
|
+
|
|
32
36
|
private
|
|
33
37
|
|
|
38
|
+
def issue_path(id, action) = "issues/#{Faraday::Utils.escape(id)}/#{action}"
|
|
39
|
+
|
|
40
|
+
# A 2xx means Pylon delivered the message, whatever the body says:
|
|
41
|
+
# refusing an unexpected body would invite the operator to send it twice.
|
|
42
|
+
def post_message(path, attributes)
|
|
43
|
+
must_succeed("create(#{path})") do
|
|
44
|
+
body = connection.post(path, attributes).body
|
|
45
|
+
body['data'] if body.is_a?(Hash) && body['data'].is_a?(Hash)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
34
49
|
def post_resource(resource, attributes)
|
|
35
50
|
operation = "create(#{resource})"
|
|
36
51
|
|
|
@@ -53,11 +53,19 @@ module ForestAdminDatasourcePylon
|
|
|
53
53
|
# when asked to, so a future default page size stays handled rather than
|
|
54
54
|
# silently truncating the thread.
|
|
55
55
|
def fetch_issue_messages(issue_id)
|
|
56
|
-
path =
|
|
56
|
+
path = issue_messages_path(issue_id)
|
|
57
57
|
|
|
58
58
|
best_effort("fetch_issue_messages(#{issue_id})", default: nil) { must_succeed(path) { collect_pages(path) } }
|
|
59
59
|
end
|
|
60
60
|
|
|
61
|
+
# For a caller acting on the thread: a thread that could not be read, or
|
|
62
|
+
# only in part, raises instead of reading as a conversation without it.
|
|
63
|
+
def fetch_complete_issue_messages(issue_id)
|
|
64
|
+
path = issue_messages_path(issue_id)
|
|
65
|
+
|
|
66
|
+
must_succeed(path) { collect_pages(path, refuse_past_cap: true) }
|
|
67
|
+
end
|
|
68
|
+
|
|
61
69
|
def search_accounts(limit:, cursor: nil, filter: nil, search_text: nil)
|
|
62
70
|
search_resource('accounts/search', limit: limit, cursor: cursor, filter: filter, search_text: search_text)
|
|
63
71
|
end
|
|
@@ -144,6 +152,8 @@ module ForestAdminDatasourcePylon
|
|
|
144
152
|
|
|
145
153
|
private
|
|
146
154
|
|
|
155
|
+
def issue_messages_path(issue_id) = "issues/#{Faraday::Utils.escape(issue_id)}/messages"
|
|
156
|
+
|
|
147
157
|
def search_resource(path, limit:, cursor: nil, filter: nil, search_text: nil)
|
|
148
158
|
body = { 'limit' => clamp_limit(limit) }
|
|
149
159
|
body['cursor'] = cursor unless blank?(cursor)
|
|
@@ -168,12 +178,14 @@ module ForestAdminDatasourcePylon
|
|
|
168
178
|
# so the walk costs the single request it always did and only does something
|
|
169
179
|
# the day that changes.
|
|
170
180
|
#
|
|
171
|
-
# Past the cap the walk is refused rather than truncated,
|
|
172
|
-
#
|
|
173
|
-
#
|
|
174
|
-
#
|
|
175
|
-
#
|
|
176
|
-
#
|
|
181
|
+
# Past the cap the walk is refused rather than truncated, like the thread a
|
|
182
|
+
# reply acts on (`fetch_complete_issue_messages`) and unlike the thread a
|
|
183
|
+
# page shows and the custom fields: `FetchAllCollection` filters, sorts,
|
|
184
|
+
# counts and groups in memory over what this returns, and calls the result
|
|
185
|
+
# exact BECAUSE it is every record Pylon holds. Truncated, that claim would
|
|
186
|
+
# stand over a fraction of the collection -- a count answered short and
|
|
187
|
+
# presented as exact, which is the one answer this datasource refuses
|
|
188
|
+
# everywhere else.
|
|
177
189
|
def fetch_all(path, params = {})
|
|
178
190
|
must_succeed(path) { collect_pages(path, params, refuse_past_cap: true) }
|
|
179
191
|
end
|
|
@@ -193,7 +205,8 @@ module ForestAdminDatasourcePylon
|
|
|
193
205
|
# Only `next_cursor` being nil is Pylon saying there is nothing more. Every
|
|
194
206
|
# other stop leaves records behind, and `refuse_past_cap` is which of the two
|
|
195
207
|
# answers the caller wants to those: a warning and a short list, or a refusal.
|
|
196
|
-
# See `fetch_all`, whose answer is only correct whole
|
|
208
|
+
# See `fetch_all`, whose answer is only correct whole, and
|
|
209
|
+
# `fetch_complete_issue_messages`, whose caller acts on the latest message.
|
|
197
210
|
#
|
|
198
211
|
# `conn` is what a caller reading on the boot path hands its own connection
|
|
199
212
|
# through: every page of the walk is then bounded like the first.
|
|
@@ -241,10 +254,8 @@ module ForestAdminDatasourcePylon
|
|
|
241
254
|
def refuse_truncated_walk(path, reason, pages, collected)
|
|
242
255
|
raise APIError,
|
|
243
256
|
"Pylon left the walk of #{path} short after #{pages} page(s) / #{collected} record(s): #{reason}. " \
|
|
244
|
-
'That endpoint is documented as answering with the whole collection in one response,
|
|
245
|
-
'
|
|
246
|
-
'than answering over a fraction of the collection: reading it needs cursor pagination, the way ' \
|
|
247
|
-
'PylonIssue reads its own.'
|
|
257
|
+
'That endpoint is documented as answering with the whole collection in one response, and its ' \
|
|
258
|
+
'caller needs all of it. Refusing rather than answering over a fraction of it.'
|
|
248
259
|
end
|
|
249
260
|
|
|
250
261
|
def log_truncated_walk(path, reason, pages, collected)
|
|
@@ -5,7 +5,7 @@ module ForestAdminDatasourcePylon
|
|
|
5
5
|
# way the Zendesk datasource embeds a ticket's comments.
|
|
6
6
|
#
|
|
7
7
|
# Pylon has no way to read the threads of several issues at once, so the
|
|
8
|
-
# thread costs one request per row — against an endpoint allowing
|
|
8
|
+
# thread costs one request per row — against an endpoint allowing 120 per
|
|
9
9
|
# minute. The fan-out is therefore bounded like the primary-key lookups of
|
|
10
10
|
# this collection: truncated with a warning rather than turned into a rate
|
|
11
11
|
# limit error halfway through the page. A relation the projection does not
|
|
@@ -49,7 +49,19 @@ module ForestAdminDatasourcePylon
|
|
|
49
49
|
'thread_id' => attrs['thread_id'],
|
|
50
50
|
'file_urls' => attrs['file_urls'],
|
|
51
51
|
'created_at' => attrs['timestamp']
|
|
52
|
-
}.merge(flatten_author(attrs['author']))
|
|
52
|
+
}.merge(flatten_author(attrs['author'])).merge(flatten_email(attrs['email_info']))
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Null outside email. `bcc_emails` and the RFC 5322 `message_id` header
|
|
56
|
+
# are left out: neither is something a reply should be built from.
|
|
57
|
+
def flatten_email(email_info)
|
|
58
|
+
attrs = email_info.is_a?(Hash) ? email_info : {}
|
|
59
|
+
|
|
60
|
+
{
|
|
61
|
+
'email_from' => attrs['from_email'],
|
|
62
|
+
'email_to' => attrs['to_emails'],
|
|
63
|
+
'email_cc' => attrs['cc_emails']
|
|
64
|
+
}
|
|
53
65
|
end
|
|
54
66
|
|
|
55
67
|
# Pylon nests the author's contact and user sides side by side, both
|
|
@@ -40,7 +40,10 @@ module ForestAdminDatasourcePylon
|
|
|
40
40
|
'author_email' => 'String',
|
|
41
41
|
'author_avatar_url' => 'String',
|
|
42
42
|
'author_contact_id' => 'String',
|
|
43
|
-
'author_user_id' => 'String'
|
|
43
|
+
'author_user_id' => 'String',
|
|
44
|
+
'email_from' => 'String',
|
|
45
|
+
'email_to' => 'Json',
|
|
46
|
+
'email_cc' => 'Json'
|
|
44
47
|
}.freeze
|
|
45
48
|
|
|
46
49
|
# One thread is one `GET /issues/{id}/messages`, and a thread is the whole
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
module ForestAdminDatasourcePylon
|
|
2
|
+
module Plugins
|
|
3
|
+
class ReplyToIssue
|
|
4
|
+
# Every dynamic property of the form reads the same ThreadLookup state,
|
|
5
|
+
# kept on the render's context so it lives exactly as long as the render.
|
|
6
|
+
# A WeakMap would let the state be collected mid-render.
|
|
7
|
+
module FormBuilder
|
|
8
|
+
FieldType = ForestAdminDatasourceCustomizer::Decorators::Action::Types::FieldType
|
|
9
|
+
|
|
10
|
+
TYPE_LABEL = 'Reply type'.freeze
|
|
11
|
+
CHANNEL_LABEL = 'Channel'.freeze
|
|
12
|
+
TO_LABEL = 'To'.freeze
|
|
13
|
+
CC_LABEL = 'CC'.freeze
|
|
14
|
+
MESSAGE_LABEL = 'Message'.freeze
|
|
15
|
+
|
|
16
|
+
PUBLIC_REPLY = 'Public reply'.freeze
|
|
17
|
+
INTERNAL_NOTE = 'Internal note'.freeze
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
def build(datasource, opts)
|
|
22
|
+
ivar = :"@forest_admin_datasource_pylon_reply_state_#{opts.object_id}"
|
|
23
|
+
state = lambda do |context|
|
|
24
|
+
next context.instance_variable_get(ivar) if context.instance_variable_defined?(ivar)
|
|
25
|
+
|
|
26
|
+
context.instance_variable_set(ivar, ThreadLookup.call(datasource, context, opts))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
fields = []
|
|
30
|
+
fields << type_field(state) if opts[:allow_internal_note]
|
|
31
|
+
fields << channel_field(state, opts)
|
|
32
|
+
fields << recipients_field(TO_LABEL, state, opts, :to)
|
|
33
|
+
fields << recipients_field(CC_LABEL, state, opts, :cc)
|
|
34
|
+
fields << message_field
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def public_reply?(context, opts)
|
|
38
|
+
!opts[:allow_internal_note] || context.get_form_value(TYPE_LABEL) == PUBLIC_REPLY
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def type_field(state)
|
|
42
|
+
{ type: FieldType::ENUM, label: TYPE_LABEL, is_required: true,
|
|
43
|
+
enum_values: lambda { |context|
|
|
44
|
+
reply_possible?(state.call(context)) ? [PUBLIC_REPLY, INTERNAL_NOTE] : [INTERNAL_NOTE]
|
|
45
|
+
},
|
|
46
|
+
default_value: ->(context) { state.call(context).target ? PUBLIC_REPLY : INTERNAL_NOTE },
|
|
47
|
+
description: 'A public reply reaches the customer; an internal note is seen by your team only.' }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# A thread that could not be read may well have a customer message: the
|
|
51
|
+
# public reply stays offered, not picked, and picking it renders the
|
|
52
|
+
# form again, which reads the thread again.
|
|
53
|
+
def reply_possible?(state)
|
|
54
|
+
!state.target.nil? || !state.error.nil?
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def channel_field(state, opts)
|
|
58
|
+
{ type: FieldType::STRING, label: CHANNEL_LABEL, is_read_only: true,
|
|
59
|
+
if_condition: ->(context) { public_reply?(context, opts) || state.call(context).target.nil? },
|
|
60
|
+
value: ->(context) { channel_text(state.call(context), opts) },
|
|
61
|
+
description: 'Pylon delivers the reply on the channel the customer used. It cannot be changed here.' }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def channel_text(state, opts)
|
|
65
|
+
return state.error if state.error
|
|
66
|
+
return "Replies via #{state.target.channel_label}" if state.target
|
|
67
|
+
|
|
68
|
+
no_target = 'This issue has no customer-visible message, so it cannot get a public reply.'
|
|
69
|
+
opts[:allow_internal_note] ? "#{no_target} Post an internal note instead." : no_target
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def recipients_field(label, state, opts, side)
|
|
73
|
+
{ type: FieldType::STRING_LIST, label: label, is_required: side == :to,
|
|
74
|
+
if_condition: lambda { |context|
|
|
75
|
+
target = state.call(context).target
|
|
76
|
+
public_reply?(context, opts) && !target.nil? && target.email?
|
|
77
|
+
},
|
|
78
|
+
default_value: ->(context) { state.call(context).target&.public_send(side) || [] },
|
|
79
|
+
description: 'Pre-filled from the latest email of the conversation, support addresses left out.' }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def message_field
|
|
83
|
+
{ type: FieldType::STRING, label: MESSAGE_LABEL, widget: 'RichText', is_required: true }
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
module ForestAdminDatasourcePylon
|
|
2
|
+
module Plugins
|
|
3
|
+
class ReplyToIssue
|
|
4
|
+
# The active Pylon user a reply is signed as, matched on the email of the
|
|
5
|
+
# Forest user running the action. Anything short of exactly one match
|
|
6
|
+
# refuses the reply rather than letting the token sign it instead.
|
|
7
|
+
module OperatorLookup
|
|
8
|
+
Result = Struct.new(:user, :error, keyword_init: true)
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def call(datasource, caller)
|
|
13
|
+
email = caller.respond_to?(:email) ? caller.email.to_s.strip.downcase : ''
|
|
14
|
+
return Result.new(error: 'Your Forest user has no email to sign the reply with.') if email.empty?
|
|
15
|
+
|
|
16
|
+
users = datasource.client.fetch_users(include_deactivated: false)
|
|
17
|
+
matches = users.select { |user| active?(user) && addresses(user).include?(email) }
|
|
18
|
+
return Result.new(error: refusal(email, matches.size)) unless matches.size == 1
|
|
19
|
+
return Result.new(user: matches.first) unless matches.first['id'].to_s.strip.empty?
|
|
20
|
+
|
|
21
|
+
Result.new(error: "The Pylon user with the email #{email} has no id, so the reply cannot be signed in " \
|
|
22
|
+
'your name and was not sent.')
|
|
23
|
+
rescue APIError => e
|
|
24
|
+
Result.new(error: "Could not read the Pylon users to sign the reply in your name: #{e.message}")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def active?(user)
|
|
28
|
+
user.is_a?(Hash) && user['is_deactivated'] != true
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def addresses(user)
|
|
32
|
+
[user['email'], *Array(user['emails'])].map { |address| address.to_s.strip.downcase }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def refusal(email, count)
|
|
36
|
+
if count.zero?
|
|
37
|
+
return "No active Pylon user has the email #{email}, so the reply cannot be signed in your name and " \
|
|
38
|
+
'was not sent. Use the same email in Forest and Pylon.'
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
"#{count} active Pylon users have the email #{email}, so the reply cannot be signed in your name and " \
|
|
42
|
+
'was not sent.'
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
module ForestAdminDatasourcePylon
|
|
2
|
+
module Plugins
|
|
3
|
+
class ReplyToIssue
|
|
4
|
+
# The customer-visible message a public reply answers. The channel is the
|
|
5
|
+
# `source` of the first such message; the reply goes to the latest message
|
|
6
|
+
# on that channel, so an email answers the latest recipients rather than
|
|
7
|
+
# those of the opening message.
|
|
8
|
+
class ReplyTarget
|
|
9
|
+
EMAIL_SOURCE = 'email'.freeze
|
|
10
|
+
CHANNEL_LABELS = { 'email' => 'email', 'slack' => 'Slack' }.freeze
|
|
11
|
+
|
|
12
|
+
attr_reader :message_id, :source, :to, :cc
|
|
13
|
+
|
|
14
|
+
def self.resolve(messages, support_emails: [])
|
|
15
|
+
visible = Array(messages).select { |message| message.is_a?(Hash) && message['is_private'] != true }
|
|
16
|
+
return nil if visible.empty?
|
|
17
|
+
|
|
18
|
+
source = visible.first['source']
|
|
19
|
+
excluded = support_emails.map(&:downcase) + team_addresses(visible)
|
|
20
|
+
new(visible.reverse.find { |message| message['source'] == source }, excluded)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# The addresses the team itself wrote from, read off the thread: what
|
|
24
|
+
# keeps a reply out of the support inbox when `support_emails` misses one.
|
|
25
|
+
def self.team_addresses(messages)
|
|
26
|
+
messages.select { |message| team_authored?(message) }
|
|
27
|
+
.map { |message| email_info(message)['from_email'].to_s.strip.downcase }
|
|
28
|
+
.reject(&:empty?)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.email_info(message)
|
|
32
|
+
message['email_info'].is_a?(Hash) ? message['email_info'] : {}
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.team_authored?(message)
|
|
36
|
+
author = message['author']
|
|
37
|
+
author.is_a?(Hash) && author['user'].is_a?(Hash)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.unique_addresses(addresses, excluded = [])
|
|
41
|
+
addresses.map { |address| address.to_s.strip }
|
|
42
|
+
.reject { |address| address.empty? || excluded.include?(address.downcase) }
|
|
43
|
+
.uniq(&:downcase)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def initialize(message, excluded)
|
|
47
|
+
@message_id = message['id']
|
|
48
|
+
@source = message['source']
|
|
49
|
+
@email_info = self.class.email_info(message)
|
|
50
|
+
@excluded = excluded
|
|
51
|
+
@to, @cc = sanitize(to: default_to(message), cc: @email_info['cc_emails'])
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# The excluded addresses come back apart, so an operator who typed one
|
|
55
|
+
# is told it was left out rather than finding it silently gone.
|
|
56
|
+
def sanitize(to:, cc:)
|
|
57
|
+
to_kept = self.class.unique_addresses(Array(to), @excluded)
|
|
58
|
+
cc_kept = self.class.unique_addresses(Array(cc), @excluded + to_kept.map(&:downcase))
|
|
59
|
+
dropped = (Array(to) + Array(cc)).map { |address| address.to_s.strip }
|
|
60
|
+
.select { |address| @excluded.include?(address.downcase) }
|
|
61
|
+
.uniq(&:downcase)
|
|
62
|
+
[to_kept, cc_kept, dropped]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def email?
|
|
66
|
+
@source == EMAIL_SOURCE || !@email_info['from_email'].to_s.empty? || Array(@email_info['to_emails']).any?
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def channel_label
|
|
70
|
+
return CHANNEL_LABELS[EMAIL_SOURCE] if email?
|
|
71
|
+
|
|
72
|
+
CHANNEL_LABELS.fetch(@source.to_s) { @source.to_s.empty? ? 'an unnamed channel' : @source.to_s.tr('_', ' ') }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
# Answering a customer's email writes back to its sender only: its own
|
|
78
|
+
# To is where the customer wrote, the support inbox among them. Answering
|
|
79
|
+
# the team's last email writes again to whom it went.
|
|
80
|
+
def default_to(message)
|
|
81
|
+
self.class.team_authored?(message) ? @email_info['to_emails'] : [@email_info['from_email']]
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
module ForestAdminDatasourcePylon
|
|
2
|
+
module Plugins
|
|
3
|
+
class ReplyToIssue
|
|
4
|
+
# The issue an action targets and the reply target its thread resolves to,
|
|
5
|
+
# or why neither could be had. `error` set means `target` is not.
|
|
6
|
+
module ThreadLookup
|
|
7
|
+
State = Struct.new(:issue_id, :target, :error, keyword_init: true)
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def call(datasource, context, opts)
|
|
12
|
+
issue_id = issue_id(context, opts)
|
|
13
|
+
return State.new(error: no_issue(opts[:issue_id_field])) if issue_id.nil?
|
|
14
|
+
|
|
15
|
+
read(datasource, issue_id, opts)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def issue_id(context, opts)
|
|
19
|
+
IssueTargets.resolve_issue_ids(context, opts[:issue_id_field]).first
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def read(datasource, issue_id, opts)
|
|
23
|
+
messages = datasource.client.fetch_complete_issue_messages(issue_id)
|
|
24
|
+
State.new(issue_id: issue_id,
|
|
25
|
+
target: ReplyTarget.resolve(messages, support_emails: opts[:support_emails]))
|
|
26
|
+
rescue APIError => e
|
|
27
|
+
State.new(issue_id: issue_id, error: "Could not read the conversation of Pylon issue #{issue_id}: " \
|
|
28
|
+
"#{e.message}")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def no_issue(field)
|
|
32
|
+
field.nil? ? 'No Pylon issue selected.' : "No Pylon issue id found in '#{field}'."
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
require 'cgi'
|
|
2
|
+
|
|
3
|
+
module ForestAdminDatasourcePylon
|
|
4
|
+
module Plugins
|
|
5
|
+
# Answers a Pylon issue on the channel the customer used, or posts an
|
|
6
|
+
# internal note on it. See the README for the channel rule and who signs.
|
|
7
|
+
class ReplyToIssue < ForestAdminDatasourceCustomizer::Plugins::Plugin
|
|
8
|
+
BaseAction = ForestAdminDatasourceCustomizer::Decorators::Action::BaseAction
|
|
9
|
+
ActionScope = ForestAdminDatasourceCustomizer::Decorators::Action::Types::ActionScope
|
|
10
|
+
ForestException = ForestAdminDatasourceToolkit::Exceptions::ForestException
|
|
11
|
+
|
|
12
|
+
NAME = 'Reply to Pylon issue'.freeze
|
|
13
|
+
|
|
14
|
+
def run(_datasource_customizer, collection_customizer = nil, options = {})
|
|
15
|
+
options = {} unless options.is_a?(Hash)
|
|
16
|
+
datasource = options[:datasource]
|
|
17
|
+
raise ForestException, 'ReplyToIssue plugin requires :datasource' unless datasource
|
|
18
|
+
raise ForestException, 'ReplyToIssue plugin requires a collection' unless collection_customizer
|
|
19
|
+
|
|
20
|
+
opts = {
|
|
21
|
+
issue_id_field: options[:issue_id_field],
|
|
22
|
+
allow_internal_note: options.fetch(:allow_internal_note, true) ? true : false,
|
|
23
|
+
post_as_operator: options[:post_as_operator] ? true : false,
|
|
24
|
+
support_emails: normalize_emails(options[:support_emails])
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
collection_customizer.add_action((options[:action_name] || NAME).to_s, build_action(datasource, opts))
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def normalize_emails(value)
|
|
33
|
+
Array(value).map { |email| email.to_s.strip.downcase }.reject(&:empty?).uniq
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def build_action(datasource, opts)
|
|
37
|
+
BaseAction.new(scope: ActionScope::SINGLE, form: FormBuilder.build(datasource, opts),
|
|
38
|
+
&executor(datasource, opts))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# A note needs the issue only. A reply reads the thread again rather than
|
|
42
|
+
# trusting the form: the conversation may have moved since it rendered.
|
|
43
|
+
def executor(datasource, opts)
|
|
44
|
+
lambda do |context, result_builder|
|
|
45
|
+
issue_id = ThreadLookup.issue_id(context, opts)
|
|
46
|
+
next result_builder.error(message: ThreadLookup.no_issue(opts[:issue_id_field])) if issue_id.nil?
|
|
47
|
+
|
|
48
|
+
body = context.form_values[FormBuilder::MESSAGE_LABEL].to_s
|
|
49
|
+
next result_builder.error(message: 'A message is required.') if blank_html?(body)
|
|
50
|
+
|
|
51
|
+
status, message = if FormBuilder.public_reply?(context, opts)
|
|
52
|
+
reply(datasource, ThreadLookup.read(datasource, issue_id, opts), body, context, opts)
|
|
53
|
+
else
|
|
54
|
+
note(datasource, issue_id, body)
|
|
55
|
+
end
|
|
56
|
+
result_builder.public_send(status, message: message)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# An emptied rich-text editor still submits markup such as `<p><br></p>`.
|
|
61
|
+
def blank_html?(body)
|
|
62
|
+
return false if body.match?(/<img\b/i)
|
|
63
|
+
|
|
64
|
+
text = CGI.unescapeHTML(body.gsub(/<[^>]*>/, ' ').gsub(/ /i, ' '))
|
|
65
|
+
text.gsub(/[[:space:]\u200B-\u200D\u2060\uFEFF]/, '').empty?
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def reply(datasource, state, body, context, opts)
|
|
69
|
+
return [:error, state.error] if state.error
|
|
70
|
+
return [:error, no_target_message(state.issue_id, opts)] if state.target.nil?
|
|
71
|
+
|
|
72
|
+
payload, dropped, refusal = reply_payload(state.target, body, context)
|
|
73
|
+
return [:error, refusal] if refusal
|
|
74
|
+
|
|
75
|
+
operator = OperatorLookup.call(datasource, context.caller) if opts[:post_as_operator]
|
|
76
|
+
return [:error, operator.error] if operator&.error
|
|
77
|
+
|
|
78
|
+
payload['user_id'] = operator.user['id'] if operator
|
|
79
|
+
write(state.issue_id, 'reply', reply_success(state, payload, operator&.user) + left_out(dropped)) do
|
|
80
|
+
datasource.client.reply_to_issue(state.issue_id, payload)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def reply_payload(target, body, context)
|
|
85
|
+
payload = { 'body_html' => body, 'message_id' => target.message_id }
|
|
86
|
+
return [payload, []] unless target.email?
|
|
87
|
+
|
|
88
|
+
to, cc, dropped = target.sanitize(to: context.form_values[FormBuilder::TO_LABEL],
|
|
89
|
+
cc: context.form_values[FormBuilder::CC_LABEL])
|
|
90
|
+
refusal = "At least one recipient (To) is required to reply by email.#{left_out(dropped)}" if to.empty?
|
|
91
|
+
return [nil, dropped, refusal] if refusal
|
|
92
|
+
|
|
93
|
+
[payload.merge('email_info' => { 'to_emails' => to, 'cc_emails' => cc }), dropped]
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def left_out(dropped)
|
|
97
|
+
dropped.empty? ? '' : " Left out as addresses of your team: #{dropped.join(", ")}."
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def note(datasource, issue_id, body)
|
|
101
|
+
write(issue_id, 'internal note', "Internal note added to Pylon issue #{issue_id}.") do
|
|
102
|
+
datasource.client.create_issue_note(issue_id, 'body_html' => body)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# A 4xx is Pylon naming what is wrong, handed over as it is. Anything else
|
|
107
|
+
# may have failed after Pylon delivered the message, so the operator is
|
|
108
|
+
# told to check before sending it again rather than shown a bare failure.
|
|
109
|
+
def write(issue_id, what, success)
|
|
110
|
+
yield
|
|
111
|
+
[:success, success]
|
|
112
|
+
rescue APIError => e
|
|
113
|
+
ForestAdminDatasourcePylon.logger.warn(
|
|
114
|
+
"[forest_admin_datasource_pylon] #{what} on issue #{issue_id} failed: #{e.message}"
|
|
115
|
+
)
|
|
116
|
+
return [:error, e.message] if (400..499).cover?(e.status.to_i)
|
|
117
|
+
|
|
118
|
+
[:error, "Pylon did not confirm the #{what} on issue #{issue_id} (#{e.message}). It may have been " \
|
|
119
|
+
'delivered: check the issue in Pylon before sending it again.']
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def no_target_message(issue_id, opts)
|
|
123
|
+
message = "Pylon issue #{issue_id} has no customer-visible message, so it cannot get a public reply."
|
|
124
|
+
opts[:allow_internal_note] ? "#{message} Post an internal note instead." : message
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def reply_success(state, payload, operator)
|
|
128
|
+
signed = if operator
|
|
129
|
+
"signed by #{[operator["name"], operator["email"]].find { |v| !v.to_s.strip.empty? }}"
|
|
130
|
+
else
|
|
131
|
+
'signed by the account of the Pylon API token'
|
|
132
|
+
end
|
|
133
|
+
if payload['email_info']
|
|
134
|
+
return "Reply emailed to #{payload["email_info"]["to_emails"].join(", ")} on Pylon issue " \
|
|
135
|
+
"#{state.issue_id}, #{signed}."
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
"Reply sent via #{state.target.channel_label} on Pylon issue #{state.issue_id}, #{signed}."
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -40,6 +40,8 @@ module ForestAdminDatasourcePylon
|
|
|
40
40
|
['get /issues/:id/messages', :get, %r{\A/issues/#{ID}/messages\z}, 120],
|
|
41
41
|
['get /issues', :get, %r{\A/issues\z}, 30],
|
|
42
42
|
['post /issues', :post, %r{\A/issues\z}, 30],
|
|
43
|
+
['post /issues/:id/reply', :post, %r{\A/issues/#{ID}/reply\z}, 30],
|
|
44
|
+
['post /issues/:id/note', :post, %r{\A/issues/#{ID}/note\z}, 30],
|
|
43
45
|
['get /issues/:id', :get, %r{\A/issues/#{ID}\z}, 300],
|
|
44
46
|
['patch /issues/:id', :patch, %r{\A/issues/#{ID}\z}, 120],
|
|
45
47
|
['get /accounts', :get, %r{\A/accounts\z}, 300],
|
metadata
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: forest_admin_datasource_pylon
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.48.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Forest Admin
|
|
8
|
-
autorequire:
|
|
9
8
|
bindir: exe
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
12
11
|
dependencies:
|
|
13
12
|
- !ruby/object:Gem::Dependency
|
|
14
13
|
name: activesupport
|
|
@@ -58,14 +57,14 @@ dependencies:
|
|
|
58
57
|
requirements:
|
|
59
58
|
- - ">="
|
|
60
59
|
- !ruby/object:Gem::Version
|
|
61
|
-
version: 1.
|
|
60
|
+
version: 1.48.0
|
|
62
61
|
type: :runtime
|
|
63
62
|
prerelease: false
|
|
64
63
|
version_requirements: !ruby/object:Gem::Requirement
|
|
65
64
|
requirements:
|
|
66
65
|
- - ">="
|
|
67
66
|
- !ruby/object:Gem::Version
|
|
68
|
-
version: 1.
|
|
67
|
+
version: 1.48.0
|
|
69
68
|
- !ruby/object:Gem::Dependency
|
|
70
69
|
name: zeitwerk
|
|
71
70
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -128,6 +127,11 @@ files:
|
|
|
128
127
|
- lib/forest_admin_datasource_pylon/plugins/create_issue_with_notification/form_builder.rb
|
|
129
128
|
- lib/forest_admin_datasource_pylon/plugins/create_issue_with_notification/payload.rb
|
|
130
129
|
- lib/forest_admin_datasource_pylon/plugins/issue_targets.rb
|
|
130
|
+
- lib/forest_admin_datasource_pylon/plugins/reply_to_issue.rb
|
|
131
|
+
- lib/forest_admin_datasource_pylon/plugins/reply_to_issue/form_builder.rb
|
|
132
|
+
- lib/forest_admin_datasource_pylon/plugins/reply_to_issue/operator_lookup.rb
|
|
133
|
+
- lib/forest_admin_datasource_pylon/plugins/reply_to_issue/reply_target.rb
|
|
134
|
+
- lib/forest_admin_datasource_pylon/plugins/reply_to_issue/thread_lookup.rb
|
|
131
135
|
- lib/forest_admin_datasource_pylon/query/condition_tree_translator.rb
|
|
132
136
|
- lib/forest_admin_datasource_pylon/query/filter_value.rb
|
|
133
137
|
- lib/forest_admin_datasource_pylon/query/operator_maps.rb
|
|
@@ -145,7 +149,6 @@ metadata:
|
|
|
145
149
|
source_code_uri: https://github.com/ForestAdmin/agent-ruby
|
|
146
150
|
changelog_uri: https://github.com/ForestAdmin/agent-ruby/blob/main/CHANGELOG.md
|
|
147
151
|
rubygems_mfa_required: 'false'
|
|
148
|
-
post_install_message:
|
|
149
152
|
rdoc_options: []
|
|
150
153
|
require_paths:
|
|
151
154
|
- lib
|
|
@@ -160,8 +163,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
160
163
|
- !ruby/object:Gem::Version
|
|
161
164
|
version: '0'
|
|
162
165
|
requirements: []
|
|
163
|
-
rubygems_version: 3.
|
|
164
|
-
signing_key:
|
|
166
|
+
rubygems_version: 3.6.7
|
|
165
167
|
specification_version: 4
|
|
166
168
|
summary: Pylon datasource for Forest Admin Ruby agent.
|
|
167
169
|
test_files: []
|