forest_admin_datasource_pylon 1.46.3 → 1.47.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2ead8ac3c18731f9fe23db9c84a1c71f365031ef38e6185aa7106a87d5c36afd
4
- data.tar.gz: b939088b93839248e32253a8381ee5bda51fa12cad1a843b06079c8657f94d27
3
+ metadata.gz: 2ab3c634549d7590768fe410e2d29cde8d9622efc0c4168eabc0bd3d26847e31
4
+ data.tar.gz: 38d1c8aca5c05b42941b623d9e85d0c5bcfeb0e1e4cc37b94b62f3468e1b5cdb
5
5
  SHA512:
6
- metadata.gz: 670ed9a6f499f36290f1d327d3978cf4a8d65753d0d57f72fb97cf753742a2a16f3c0a7accb8f8daa8f13a9f52bcbe04a8b0fb2489f4d36696dfcc81356371c2
7
- data.tar.gz: 783f956f641dea65ff7003a06d98e5d5983e6683f201133904f6d000d2af05c130abf795bca3db96a58b02723a9772cdf3c625ec8e088d50a0481ae0337507b2
6
+ metadata.gz: 357ae533c1057c069721a5646e062665167e25bbe21ed045becc7be01a73b84cb6ecc97978d6b62329dadd9fee888bfb443fe611faee26f054f1ad07cdf7e18c
7
+ data.tar.gz: 6bf1a453cd8f0b8e9d985acea5786cd075027e5a321b91e279a5d8d29e97aec2dff883a246e7101712703ae75761d90a37cde2c61ca5f1a6ca6076eb2e21592e
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 = "issues/#{Faraday::Utils.escape(issue_id)}/messages"
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, unlike the thread
172
- # and the custom fields: `FetchAllCollection` filters, sorts, counts and
173
- # groups in memory over what this returns, and calls the result exact
174
- # BECAUSE it is every record Pylon holds. Truncated, that claim would stand
175
- # over a fraction of the collection -- a count answered short and presented
176
- # as exact, which is the one answer this datasource refuses everywhere else.
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, which is what ' \
245
- 'PylonUser and PylonTeam filter, sort and count in memory as an exact answer. Refusing rather ' \
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 20 per
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(/&nbsp;/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],
@@ -1,3 +1,3 @@
1
1
  module ForestAdminDatasourcePylon
2
- VERSION = "1.46.3"
2
+ VERSION = "1.47.0"
3
3
  end
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.46.3
4
+ version: 1.47.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Forest Admin
8
- autorequire:
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2026-10-21 00:00:00.000000000 Z
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.46.3
60
+ version: 1.47.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.46.3
67
+ version: 1.47.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.4.20
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: []