diff --git a/packages/forest_admin_datasource_pylon/README.md b/packages/forest_admin_datasource_pylon/README.md index de565ab19..a216f8c0e 100644 --- a/packages/forest_admin_datasource_pylon/README.md +++ b/packages/forest_admin_datasource_pylon/README.md @@ -169,6 +169,61 @@ The message body is HTML the operator writes and Pylon delivers to the requester requester address is a free-text field. Restrict both actions to the roles that should be able to send mail on your organization's behalf. +### `ReplyToIssue` + +Answers one issue on the channel the customer used — a Slack issue gets a Slack reply, an email +issue an email — or posts an internal note on it. Single scope only. + +```ruby +@agent.collection :Ticket do |collection| + collection.use(ForestAdminDatasourcePylon::Plugins::ReplyToIssue, { + datasource: pylon_datasource, # required + issue_id_field: 'pylon_issue_id', # omit when the action sits on PylonIssue itself + action_name: 'Reply to Pylon issue', # default + allow_internal_note: true, # default + support_emails: ['support@example.com'], + post_as_operator: false # default + }) +end +``` + +**Which channel.** Pylon delivers a reply into the conversation of the message it names, so the +action picks that message: the channel is the one of the first customer-visible message, and the +reply answers the latest customer-visible message on that channel. The form shows the channel +read-only. An issue with no customer-visible message — one created as an internal note, for +instance — cannot get a public reply; only the internal note is offered. + +**Email recipients.** Pylon does not infer them, so the form pre-fills To and CC from the latest +email of the conversation. When the customer wrote it, To is its sender only: the addresses the +customer wrote to include your support inbox. When the team wrote it, To is whom it went to. The +`support_emails` and the addresses the team wrote from in the thread are always left out, from what +the operator types as well, and the result message names any typed address left out. Set +`support_emails`: before the team's first email in a thread, it is the only way to know your own +addresses. A reply with no To is refused. + +**When Pylon does not confirm.** A 4xx is shown with Pylon's own reason. Any other failure, a +timeout included, may come after Pylon delivered the message, so the action says so and asks to +check the issue before sending again. + +**Who signs.** By default the account owning the API token signs every reply, and the success +message says so. With `post_as_operator: true`, a public reply is signed by the active Pylon user +whose email matches the Forest user's, primary or secondary address. With no such user, or several, +the reply is refused and nothing is sent: it never falls back to the token. Internal notes are +always signed by the token. + +Three things to know before granting it. + +- **The column is the authority.** With `issue_id_field`, as with `CloseIssue`, an operator who can + write that column can reply to any Pylon issue, on your organization's behalf. +- **`post_as_operator` trusts emails.** The token can sign as any agent of the workspace; the only + thing tying a Forest user to a Pylon user is that they share an email. Keep both user lists + consistent, and restrict the action to the roles that should answer customers. +- **The thread is read on every form render**, one `GET /issues/{id}/messages` (120 a minute). + A submit costs one or two more: the agent renders the form again before running the action, and + a public reply reads the thread once more to answer its latest state, plus one `GET /users` with + `post_as_operator`. An unreadable thread is shown as an error, never as an issue without customer + message: both reply types stay offered, and an internal note does not depend on the thread. + ## Development ```bash diff --git a/packages/forest_admin_datasource_pylon/lib/forest_admin_datasource_pylon/client.rb b/packages/forest_admin_datasource_pylon/lib/forest_admin_datasource_pylon/client.rb index 6a315cbf8..f6e4104ef 100644 --- a/packages/forest_admin_datasource_pylon/lib/forest_admin_datasource_pylon/client.rb +++ b/packages/forest_admin_datasource_pylon/lib/forest_admin_datasource_pylon/client.rb @@ -53,11 +53,19 @@ def fetch_issue(id) # when asked to, so a future default page size stays handled rather than # silently truncating the thread. def fetch_issue_messages(issue_id) - path = "issues/#{Faraday::Utils.escape(issue_id)}/messages" + path = issue_messages_path(issue_id) best_effort("fetch_issue_messages(#{issue_id})", default: nil) { must_succeed(path) { collect_pages(path) } } end + # For a caller acting on the thread: a thread that could not be read, or + # only in part, raises instead of reading as a conversation without it. + def fetch_complete_issue_messages(issue_id) + path = issue_messages_path(issue_id) + + must_succeed(path) { collect_pages(path, refuse_past_cap: true) } + end + def search_accounts(limit:, cursor: nil, filter: nil, search_text: nil) search_resource('accounts/search', limit: limit, cursor: cursor, filter: filter, search_text: search_text) end @@ -144,6 +152,8 @@ def inspect private + def issue_messages_path(issue_id) = "issues/#{Faraday::Utils.escape(issue_id)}/messages" + def search_resource(path, limit:, cursor: nil, filter: nil, search_text: nil) body = { 'limit' => clamp_limit(limit) } body['cursor'] = cursor unless blank?(cursor) @@ -168,12 +178,14 @@ def list_resource(path, limit:, cursor: nil) # so the walk costs the single request it always did and only does something # the day that changes. # - # Past the cap the walk is refused rather than truncated, unlike the thread - # and the custom fields: `FetchAllCollection` filters, sorts, counts and - # groups in memory over what this returns, and calls the result exact - # BECAUSE it is every record Pylon holds. Truncated, that claim would stand - # over a fraction of the collection -- a count answered short and presented - # as exact, which is the one answer this datasource refuses everywhere else. + # Past the cap the walk is refused rather than truncated, like the thread a + # reply acts on (`fetch_complete_issue_messages`) and unlike the thread a + # page shows and the custom fields: `FetchAllCollection` filters, sorts, + # counts and groups in memory over what this returns, and calls the result + # exact BECAUSE it is every record Pylon holds. Truncated, that claim would + # stand over a fraction of the collection -- a count answered short and + # presented as exact, which is the one answer this datasource refuses + # everywhere else. def fetch_all(path, params = {}) must_succeed(path) { collect_pages(path, params, refuse_past_cap: true) } end @@ -193,7 +205,8 @@ def fetch_all(path, params = {}) # Only `next_cursor` being nil is Pylon saying there is nothing more. Every # other stop leaves records behind, and `refuse_past_cap` is which of the two # answers the caller wants to those: a warning and a short list, or a refusal. - # See `fetch_all`, whose answer is only correct whole. + # See `fetch_all`, whose answer is only correct whole, and + # `fetch_complete_issue_messages`, whose caller acts on the latest message. # # `conn` is what a caller reading on the boot path hands its own connection # through: every page of the walk is then bounded like the first. @@ -241,10 +254,8 @@ def truncation_reason(page, pages, seen) def refuse_truncated_walk(path, reason, pages, collected) raise APIError, "Pylon left the walk of #{path} short after #{pages} page(s) / #{collected} record(s): #{reason}. " \ - 'That endpoint is documented as answering with the whole collection in one response, which is what ' \ - 'PylonUser and PylonTeam filter, sort and count in memory as an exact answer. Refusing rather ' \ - 'than answering over a fraction of the collection: reading it needs cursor pagination, the way ' \ - 'PylonIssue reads its own.' + 'That endpoint is documented as answering with the whole collection in one response, and its ' \ + 'caller needs all of it. Refusing rather than answering over a fraction of it.' end def log_truncated_walk(path, reason, pages, collected) diff --git a/packages/forest_admin_datasource_pylon/lib/forest_admin_datasource_pylon/client/writes.rb b/packages/forest_admin_datasource_pylon/lib/forest_admin_datasource_pylon/client/writes.rb index 9a50e9cb9..a4e2d8045 100644 --- a/packages/forest_admin_datasource_pylon/lib/forest_admin_datasource_pylon/client/writes.rb +++ b/packages/forest_admin_datasource_pylon/lib/forest_admin_datasource_pylon/client/writes.rb @@ -29,8 +29,23 @@ def update_team(id, attributes) = patch_resource('teams', id, attributes) def update_user(id, attributes) = patch_resource('users', id, attributes) + # `message_id` picks the customer conversation the reply is delivered to. + def reply_to_issue(id, attributes) = post_message(issue_path(id, 'reply'), attributes) + def create_issue_note(id, attributes) = post_message(issue_path(id, 'note'), attributes) + private + def issue_path(id, action) = "issues/#{Faraday::Utils.escape(id)}/#{action}" + + # A 2xx means Pylon delivered the message, whatever the body says: + # refusing an unexpected body would invite the operator to send it twice. + def post_message(path, attributes) + must_succeed("create(#{path})") do + body = connection.post(path, attributes).body + body['data'] if body.is_a?(Hash) && body['data'].is_a?(Hash) + end + end + def post_resource(resource, attributes) operation = "create(#{resource})" diff --git a/packages/forest_admin_datasource_pylon/lib/forest_admin_datasource_pylon/collections/issue.rb b/packages/forest_admin_datasource_pylon/lib/forest_admin_datasource_pylon/collections/issue.rb index 8367d47ce..ba15e905e 100644 --- a/packages/forest_admin_datasource_pylon/lib/forest_admin_datasource_pylon/collections/issue.rb +++ b/packages/forest_admin_datasource_pylon/lib/forest_admin_datasource_pylon/collections/issue.rb @@ -40,7 +40,10 @@ class Issue < BaseCollection 'author_email' => 'String', 'author_avatar_url' => 'String', 'author_contact_id' => 'String', - 'author_user_id' => 'String' + 'author_user_id' => 'String', + 'email_from' => 'String', + 'email_to' => 'Json', + 'email_cc' => 'Json' }.freeze # One thread is one `GET /issues/{id}/messages`, and a thread is the whole diff --git a/packages/forest_admin_datasource_pylon/lib/forest_admin_datasource_pylon/collections/issue/messages_embedder.rb b/packages/forest_admin_datasource_pylon/lib/forest_admin_datasource_pylon/collections/issue/messages_embedder.rb index 758e85f9a..1ab963cf7 100644 --- a/packages/forest_admin_datasource_pylon/lib/forest_admin_datasource_pylon/collections/issue/messages_embedder.rb +++ b/packages/forest_admin_datasource_pylon/lib/forest_admin_datasource_pylon/collections/issue/messages_embedder.rb @@ -5,7 +5,7 @@ class Issue < BaseCollection # way the Zendesk datasource embeds a ticket's comments. # # Pylon has no way to read the threads of several issues at once, so the - # thread costs one request per row — against an endpoint allowing 20 per + # thread costs one request per row — against an endpoint allowing 120 per # minute. The fan-out is therefore bounded like the primary-key lookups of # this collection: truncated with a warning rather than turned into a rate # limit error halfway through the page. A relation the projection does not @@ -49,7 +49,19 @@ def serialize_message(message) 'thread_id' => attrs['thread_id'], 'file_urls' => attrs['file_urls'], 'created_at' => attrs['timestamp'] - }.merge(flatten_author(attrs['author'])) + }.merge(flatten_author(attrs['author'])).merge(flatten_email(attrs['email_info'])) + end + + # Null outside email. `bcc_emails` and the RFC 5322 `message_id` header + # are left out: neither is something a reply should be built from. + def flatten_email(email_info) + attrs = email_info.is_a?(Hash) ? email_info : {} + + { + 'email_from' => attrs['from_email'], + 'email_to' => attrs['to_emails'], + 'email_cc' => attrs['cc_emails'] + } end # Pylon nests the author's contact and user sides side by side, both diff --git a/packages/forest_admin_datasource_pylon/lib/forest_admin_datasource_pylon/plugins/reply_to_issue.rb b/packages/forest_admin_datasource_pylon/lib/forest_admin_datasource_pylon/plugins/reply_to_issue.rb new file mode 100644 index 000000000..f0505bac8 --- /dev/null +++ b/packages/forest_admin_datasource_pylon/lib/forest_admin_datasource_pylon/plugins/reply_to_issue.rb @@ -0,0 +1,142 @@ +require 'cgi' + +module ForestAdminDatasourcePylon + module Plugins + # Answers a Pylon issue on the channel the customer used, or posts an + # internal note on it. See the README for the channel rule and who signs. + class ReplyToIssue < ForestAdminDatasourceCustomizer::Plugins::Plugin + BaseAction = ForestAdminDatasourceCustomizer::Decorators::Action::BaseAction + ActionScope = ForestAdminDatasourceCustomizer::Decorators::Action::Types::ActionScope + ForestException = ForestAdminDatasourceToolkit::Exceptions::ForestException + + NAME = 'Reply to Pylon issue'.freeze + + def run(_datasource_customizer, collection_customizer = nil, options = {}) + options = {} unless options.is_a?(Hash) + datasource = options[:datasource] + raise ForestException, 'ReplyToIssue plugin requires :datasource' unless datasource + raise ForestException, 'ReplyToIssue plugin requires a collection' unless collection_customizer + + opts = { + issue_id_field: options[:issue_id_field], + allow_internal_note: options.fetch(:allow_internal_note, true) ? true : false, + post_as_operator: options[:post_as_operator] ? true : false, + support_emails: normalize_emails(options[:support_emails]) + } + + collection_customizer.add_action((options[:action_name] || NAME).to_s, build_action(datasource, opts)) + end + + private + + def normalize_emails(value) + Array(value).map { |email| email.to_s.strip.downcase }.reject(&:empty?).uniq + end + + def build_action(datasource, opts) + BaseAction.new(scope: ActionScope::SINGLE, form: FormBuilder.build(datasource, opts), + &executor(datasource, opts)) + end + + # A note needs the issue only. A reply reads the thread again rather than + # trusting the form: the conversation may have moved since it rendered. + def executor(datasource, opts) + lambda do |context, result_builder| + issue_id = ThreadLookup.issue_id(context, opts) + next result_builder.error(message: ThreadLookup.no_issue(opts[:issue_id_field])) if issue_id.nil? + + body = context.form_values[FormBuilder::MESSAGE_LABEL].to_s + next result_builder.error(message: 'A message is required.') if blank_html?(body) + + status, message = if FormBuilder.public_reply?(context, opts) + reply(datasource, ThreadLookup.read(datasource, issue_id, opts), body, context, opts) + else + note(datasource, issue_id, body) + end + result_builder.public_send(status, message: message) + end + end + + # An emptied rich-text editor still submits markup such as `
Hi
')).to eq('id' => 'm1', 'issue_id' => 'i1') + expect(WebMock).to have_requested(:post, url).with(body: { 'body_html' => 'Hi
' }) + end + + it "#{method} escapes the issue id" do + stub_request(:post, "#{base}/issues/a%2Fb/#{action}").to_return(json('data' => { 'id' => 'm1' })) + + client.public_send(method, 'a/b', 'body_html' => 'Hi
') + + expect(WebMock).to have_requested(:post, "#{base}/issues/a%2Fb/#{action}") + end + + # Pylon delivered the message once it answers 2xx: refusing the body + # would invite a second send. + it "#{method} accepts a 2xx whose body carries no record" do + stub_request(:post, "#{base}/issues/i1/#{action}").to_return(status: 204) + + expect(client.public_send(method, 'i1', 'body_html' => 'Hi
')).to be_nil + end + end + { delete_issue: 'issues', delete_account: 'accounts', delete_contact: 'contacts' }.each do |method, path| it "#{method} deletes /#{path}/{id}" do stub_request(:delete, "#{base}/#{path}/x1").to_return(status: 204) @@ -136,6 +162,32 @@ def json(payload, status = 200) expect(WebMock).to have_requested(:post, "#{base}/issues").once end + # A reply that reached the customer and is replayed is a message sent twice. + it 'does not retry a reply that failed on a gateway error' do + stub_request(:post, "#{base}/issues/i1/reply").to_return(json({ 'message' => 'bad gateway' }, 502)) + + expect { client.reply_to_issue('i1', 'body_html' => 'Hi
') } + .to raise_error(ForestAdminDatasourcePylon::APIError, %r{create\(issues/i1/reply\).*502}m) + expect(WebMock).to have_requested(:post, "#{base}/issues/i1/reply").once + end + + it 'does not retry a reply that timed out, and raises with no status' do + stub_request(:post, "#{base}/issues/i1/reply").to_timeout + + expect { client.reply_to_issue('i1', 'body_html' => 'Hi
') } + .to raise_error(ForestAdminDatasourcePylon::APIError) { |error| expect(error.status).to be_nil } + expect(WebMock).to have_requested(:post, "#{base}/issues/i1/reply").once + end + + it 'retries a rate-limited reply' do + stub_request(:post, "#{base}/issues/i1/reply") + .to_return(json({ 'message' => 'slow down' }, 429)) + .then.to_return(json('data' => { 'id' => 'm1' })) + + expect(client.reply_to_issue('i1', 'body_html' => 'Hi
')).to eq('id' => 'm1') + expect(WebMock).to have_requested(:post, "#{base}/issues/i1/reply").twice + end + it 'retries a rate-limited delete' do stub_request(:delete, "#{base}/issues/i1") .to_return(json({ 'message' => 'slow down' }, 429)) diff --git a/packages/forest_admin_datasource_pylon/spec/forest_admin_datasource_pylon/client_spec.rb b/packages/forest_admin_datasource_pylon/spec/forest_admin_datasource_pylon/client_spec.rb index 0337f924d..9b67c31dc 100644 --- a/packages/forest_admin_datasource_pylon/spec/forest_admin_datasource_pylon/client_spec.rb +++ b/packages/forest_admin_datasource_pylon/spec/forest_admin_datasource_pylon/client_spec.rb @@ -352,6 +352,53 @@ def page(records, cursor: nil) end end + describe '#fetch_complete_issue_messages' do + def page(records, cursor: nil) + body = { 'data' => records } + body['pagination'] = { 'cursor' => cursor, 'has_next_page' => true } if cursor + json(body) + end + + it 'returns the whole thread of an issue' do + stub_request(:get, "#{base}/issues/i1/messages").to_return(page([{ 'id' => 'm1' }, { 'id' => 'm2' }])) + + expect(client.fetch_complete_issue_messages('i1')).to eq([{ 'id' => 'm1' }, { 'id' => 'm2' }]) + end + + it 'escapes the issue id' do + stub_request(:get, "#{base}/issues/..%2Fme/messages").to_return(page([])) + + client.fetch_complete_issue_messages('../me') + + expect(WebMock).to have_requested(:get, "#{base}/issues/..%2Fme/messages") + end + + it 'raises when the thread cannot be read' do + stub_request(:get, "#{base}/issues/i1/messages").to_return(json({ 'message' => 'boom' }, 500)) + + expect { client.fetch_complete_issue_messages('i1') } + .to raise_error(ForestAdminDatasourcePylon::APIError, /HTTP 500 boom/) + end + + it 'raises on a missing issue' do + stub_request(:get, "#{base}/issues/nope/messages").to_return(json({ 'message' => 'not found' }, 404)) + + expect { client.fetch_complete_issue_messages('nope') } + .to raise_error(ForestAdminDatasourcePylon::APIError) { |error| expect(error.status).to eq(404) } + end + + it 'raises rather than answering part of a thread Pylon never stops paginating' do + served = 0 + stub_request(:get, %r{/issues/i1/messages}).to_return do + served += 1 + page([{ 'id' => "m#{served}" }], cursor: "c#{served}") + end + + expect { client.fetch_complete_issue_messages('i1') } + .to raise_error(ForestAdminDatasourcePylon::APIError, %r{left the walk of issues/i1/messages short}) + end + end + describe '#search_accounts' do it 'posts the full search envelope and returns the records' do stub_request(:post, "#{base}/accounts/search").to_return(json('data' => [{ 'id' => 'a1' }])) diff --git a/packages/forest_admin_datasource_pylon/spec/forest_admin_datasource_pylon/collections/issue/messages_embedder_spec.rb b/packages/forest_admin_datasource_pylon/spec/forest_admin_datasource_pylon/collections/issue/messages_embedder_spec.rb index 46ad3dddd..93b0027bd 100644 --- a/packages/forest_admin_datasource_pylon/spec/forest_admin_datasource_pylon/collections/issue/messages_embedder_spec.rb +++ b/packages/forest_admin_datasource_pylon/spec/forest_admin_datasource_pylon/collections/issue/messages_embedder_spec.rb @@ -129,6 +129,38 @@ def stub_messages(issue_id, *payloads) end end + describe 'the email envelope of a message' do + before { stub_issues(issue_payload('i1')) } + + def message_of(payload) + stub_messages('i1', payload) + issues.list(nil, filter, %w[id messages]).first['messages'].first + end + + it 'flattens the sender and recipients of an email' do + email_info = { 'from_email' => 'ada@acme.com', 'to_emails' => ['support@acme.io'], + 'cc_emails' => ['bob@acme.com'], 'bcc_emails' => ['audit@acme.io'], + 'message_id' => 'Hi
' }.merge(overrides) + end + + before do + allow(client).to receive_messages(reply_to_issue: posted, create_issue_note: posted) + end + + it 'replies to the latest message of the customer channel' do + thread(message('m1', source: 'slack'), message('m2', source: 'slack')) + + result = run_action(register, values) + + expect(client).to have_received(:reply_to_issue).with('i1', 'body_html' => 'Hi
', 'message_id' => 'm2') + expect(result[:type]).to eq('Success') + expect(result[:message]).to eq('Reply sent via Slack on Pylon issue i1, signed by the account of the ' \ + 'Pylon API token.') + end + + it 'emails the recipients the operator kept' do + thread(email_message('m1', from: 'ada@acme.com', to: ['support@us.io'])) + + result = run_action(register, + values(form::TO_LABEL => ['ada@acme.com', ' '], form::CC_LABEL => ['bob@acme.com'])) + + expect(client).to have_received(:reply_to_issue).with( + 'i1', 'body_html' => 'Hi
', 'message_id' => 'm1', + 'email_info' => { 'to_emails' => ['ada@acme.com'], 'cc_emails' => ['bob@acme.com'] } + ) + expect(result[:message]).to start_with('Reply emailed to ada@acme.com on Pylon issue i1') + end + + it 'leaves the support and team addresses out of what the operator typed' do + thread(email_message('m1', from: 'ada@acme.com', to: ['support@us.io'])) + + run_action(register(support_emails: ['support@us.io']), + values(form::TO_LABEL => ['ada@acme.com', 'Support@us.io'], + form::CC_LABEL => ['ADA@acme.com', 'support@us.io', 'bob@acme.com'])) + + expect(client).to have_received(:reply_to_issue).with( + 'i1', hash_including('email_info' => { 'to_emails' => ['ada@acme.com'], 'cc_emails' => ['bob@acme.com'] }) + ) + end + + it 'names the typed addresses it left out' do + thread(email_message('m1', from: 'ada@acme.com', to: ['support@us.io'])) + + result = run_action(register(support_emails: ['support@us.io']), + values(form::TO_LABEL => ['ada@acme.com'], form::CC_LABEL => ['Support@us.io'])) + + expect(result[:type]).to eq('Success') + expect(result[:message]).to end_with('Left out as addresses of your team: Support@us.io.') + end + + it 'says why To is empty when it only held addresses of the team' do + thread(email_message('m1', from: 'ada@acme.com', to: ['support@us.io'])) + + result = run_action(register(support_emails: ['support@us.io']), values(form::TO_LABEL => ['support@us.io'])) + + expect(result[:message]).to eq('At least one recipient (To) is required to reply by email. Left out as ' \ + 'addresses of your team: support@us.io.') + expect(client).not_to have_received(:reply_to_issue) + end + + it 'refuses an email reply with no recipient, and sends nothing' do + thread(email_message('m1', from: 'ada@acme.com', to: [])) + + result = run_action(register, values(form::TO_LABEL => [])) + + expect(result[:type]).to eq('Error') + expect(result[:message]).to match(/At least one recipient/) + expect(client).not_to have_received(:reply_to_issue) + end + + it 'posts an internal note with no target' do + thread(message('m1', source: 'slack')) + + result = run_action(register, values(form::TYPE_LABEL => form::INTERNAL_NOTE)) + + expect(client).to have_received(:create_issue_note).with('i1', 'body_html' => 'Hi
') + expect(result[:message]).to eq('Internal note added to Pylon issue i1.') + end + + it 'never posts a note from an action that does not offer one' do + thread(message('m1', source: 'slack')) + + run_action(register(allow_internal_note: false), values(form::TYPE_LABEL => form::INTERNAL_NOTE)) + + expect(client).not_to have_received(:create_issue_note) + expect(client).to have_received(:reply_to_issue) + end + + it 'refuses a public reply on an issue with no customer message' do + thread(message('n1', source: 'internal', is_private: true)) + + result = run_action(register, values) + + expect(result[:type]).to eq('Error') + expect(result[:message]).to match(/i1 has no customer-visible message.*internal note instead/) + expect(client).not_to have_received(:reply_to_issue) + end + + it 'reports a failed thread read as such, never as an issue without customer message' do + allow(client).to receive(:fetch_complete_issue_messages) + .and_raise(ForestAdminDatasourcePylon::APIError.new('HTTP 503 down', status: 503)) + + result = run_action(register, values) + + expect(result[:message]).to eq('Could not read the conversation of Pylon issue i1: HTTP 503 down') + expect(client).not_to have_received(:reply_to_issue) + end + + it 'refuses an empty message' do + thread(message('m1', source: 'slack')) + + expect(run_action(register, values(form::MESSAGE_LABEL => ' '))[:message]).to eq('A message is required.') + expect(client).not_to have_received(:reply_to_issue) + end + + it 'names the missing issue' do + expect(run_action(register, values, record_ids: [])[:message]).to eq('No Pylon issue selected.') + expect(run_action(register(issue_id_field: 'pylon_id'), values, records: [{ 'pylon_id' => nil }])[:message]) + .to eq("No Pylon issue id found in 'pylon_id'.") + end + + it 'reads the issue id from the configured column' do + thread(message('m1', source: 'slack'), issue_id: 'i9') + + run_action(register(issue_id_field: 'pylon_id'), values, records: [{ 'pylon_id' => 'i9' }]) + + expect(client).to have_received(:reply_to_issue).with('i9', hash_including('message_id' => 'm1')) + end + + it 'hands the operator the reason Pylon refused the reply' do + thread(message('m1', source: 'slack')) + allow(client).to receive(:reply_to_issue) + .and_raise(ForestAdminDatasourcePylon::APIError.new('HTTP 400 message_id is not visible', status: 400)) + + result = run_action(register, values) + + expect(result[:type]).to eq('Error') + expect(result[:message]).to eq('HTTP 400 message_id is not visible') + end + + # Pylon may have delivered the reply before failing to answer: a bare + # failure would invite the operator to send it a second time. + it 'tells the operator to check Pylon when it did not confirm the reply' do + thread(message('m1', source: 'slack')) + allow(client).to receive(:reply_to_issue) + .and_raise(ForestAdminDatasourcePylon::APIError.new('HTTP 502', status: 502)) + + result = run_action(register, values) + + expect(result[:type]).to eq('Error') + expect(result[:message]).to eq('Pylon did not confirm the reply on issue i1 (HTTP 502). It may have been ' \ + 'delivered: check the issue in Pylon before sending it again.') + end + + it 'tells the operator to check Pylon when the reply timed out, with no status to read' do + thread(message('m1', source: 'slack')) + allow(client).to receive(:reply_to_issue) + .and_raise(ForestAdminDatasourcePylon::APIError.new('execution expired', status: nil)) + + expect(run_action(register, values)[:message]) + .to start_with('Pylon did not confirm the reply on issue i1 (execution expired). It may have been delivered') + end + + it 'posts an internal note without reading the thread, which may be unreadable' do + allow(client).to receive(:fetch_complete_issue_messages) + + run_action(register, values(form::TYPE_LABEL => form::INTERNAL_NOTE)) + + expect(client).to have_received(:create_issue_note).with('i1', 'body_html' => 'Hi
') + expect(client).not_to have_received(:fetch_complete_issue_messages) + end + + it 'refuses the markup an emptied rich-text editor submits' do + thread(message('m1', source: 'slack')) + + ['', '
\u00A0
", '
'].each do |body| + expect(run_action(register, values(form::MESSAGE_LABEL => body))[:message]).to eq('A message is required.') + end + expect(client).not_to have_received(:reply_to_issue) + end + + it 'sends a message made of an image only' do + thread(message('m1', source: 'slack')) + + run_action(register, values(form::MESSAGE_LABEL => '
Hi
' } } + + def user(id, email:, emails: [], is_deactivated: false, name: nil) + { 'id' => id, 'name' => name, 'email' => email, 'emails' => emails, 'is_deactivated' => is_deactivated } + end + + def run_as(caller, opts = {}) + run_action(register({ post_as_operator: true }.merge(opts)), values, caller: caller) + end + + before do + thread(message('m1', source: 'slack')) + allow(client).to receive_messages(reply_to_issue: { 'id' => 'r1' }, create_issue_note: { 'id' => 'n1' }) + end + + it 'passes the Pylon user sharing the operator email, whatever its case' do + allow(client).to receive(:fetch_users).and_return([user('u1', email: 'ada@acme.io', name: 'Ada')]) + + result = run_as(operator) + + expect(client).to have_received(:reply_to_issue).with('i1', hash_including('user_id' => 'u1')) + expect(result[:message]).to eq('Reply sent via Slack on Pylon issue i1, signed by Ada.') + end + + it 'matches one of the other addresses of a Pylon user' do + allow(client).to receive(:fetch_users) + .and_return([user('u2', email: 'ada@corp.io', emails: ['ada@acme.io'])]) + + run_as(operator) + + expect(client).to have_received(:reply_to_issue).with('i1', hash_including('user_id' => 'u2')) + end + + it 'names the signer by email when the Pylon user has a blank name' do + allow(client).to receive(:fetch_users).and_return([user('u1', email: 'ada@acme.io', name: ' ')]) + + expect(run_as(operator)[:message]).to end_with('signed by ada@acme.io.') + end + + it 'never signs as a deactivated Pylon user, and sends nothing' do + allow(client).to receive(:fetch_users).and_return([user('u1', email: 'ada@acme.io', is_deactivated: true)]) + + result = run_as(operator) + + expect(result[:type]).to eq('Error') + expect(result[:message]).to match(/No active Pylon user has the email ada@acme.io.*was not sent/) + expect(client).not_to have_received(:reply_to_issue) + end + + it 'refuses rather than letting the token sign when no Pylon user matches' do + allow(client).to receive(:fetch_users).and_return([user('u1', email: 'grace@acme.io')]) + + expect(run_as(operator)[:type]).to eq('Error') + expect(client).not_to have_received(:reply_to_issue) + end + + it 'refuses when several active Pylon users share the email' do + allow(client).to receive(:fetch_users) + .and_return([user('u1', email: 'ada@acme.io'), user('u2', email: 'x@acme.io', emails: ['ada@acme.io'])]) + + expect(run_as(operator)[:message]).to match(/2 active Pylon users have the email ada@acme.io/) + expect(client).not_to have_received(:reply_to_issue) + end + + it 'refuses rather than letting the token sign when the matched Pylon user has no id' do + allow(client).to receive(:fetch_users).and_return([user(nil, email: 'ada@acme.io')]) + + expect(run_as(operator)[:message]).to match(/Pylon user with the email ada@acme.io has no id.*was not sent/) + expect(client).not_to have_received(:reply_to_issue) + end + + it 'refuses when the Forest user has no email' do + allow(client).to receive(:fetch_users) + + expect(run_as(nil)[:message]).to eq('Your Forest user has no email to sign the reply with.') + expect(client).not_to have_received(:fetch_users) + end + + it 'reports a failed read of the Pylon users, and sends nothing' do + allow(client).to receive(:fetch_users) + .and_raise(ForestAdminDatasourcePylon::APIError.new('HTTP 500 boom', status: 500)) + + expect(run_as(operator)[:message]) + .to eq('Could not read the Pylon users to sign the reply in your name: HTTP 500 boom') + expect(client).not_to have_received(:reply_to_issue) + end + + it 'signs an internal note with the token, without looking the operator up' do + allow(client).to receive(:fetch_users) + + run_action(register(post_as_operator: true), values.merge(form::TYPE_LABEL => form::INTERNAL_NOTE), + caller: operator) + + expect(client).to have_received(:create_issue_note).with('i1', 'body_html' => 'Hi
') + expect(client).not_to have_received(:fetch_users) + end + + it 'leaves the token signing when the option is off' do + allow(client).to receive(:fetch_users) + + run_action(register, values, caller: operator) + + expect(client).to have_received(:reply_to_issue).with('i1', hash_excluding('user_id')) + expect(client).not_to have_received(:fetch_users) + end + end + end +end diff --git a/packages/forest_admin_datasource_pylon/spec/forest_admin_datasource_pylon/rate_limits_spec.rb b/packages/forest_admin_datasource_pylon/spec/forest_admin_datasource_pylon/rate_limits_spec.rb index c20e4b002..7a84d6988 100644 --- a/packages/forest_admin_datasource_pylon/spec/forest_admin_datasource_pylon/rate_limits_spec.rb +++ b/packages/forest_admin_datasource_pylon/spec/forest_admin_datasource_pylon/rate_limits_spec.rb @@ -24,6 +24,8 @@ def rule_for(method, path) = described_class.for(method, path) it 'reads the documented budget of the writes' do expect(rule_for(:post, '/issues').limit).to eq(30) + expect(rule_for(:post, '/issues/abc/reply').limit).to eq(30) + expect(rule_for(:post, '/issues/abc/note').limit).to eq(30) expect(rule_for(:patch, '/issues/abc').limit).to eq(120) expect(rule_for(:delete, '/issues/abc').limit).to eq(120) expect(rule_for(:post, '/teams').limit).to eq(30) @@ -60,6 +62,7 @@ def rule_for(method, path) = described_class.for(method, path) called = [ [:get, '/me'], [:post, '/issues/search'], [:get, '/issues/abc'], [:get, '/issues/abc/messages'], [:post, '/issues'], [:patch, '/issues/abc'], + [:post, '/issues/abc/reply'], [:post, '/issues/abc/note'], [:delete, '/issues/abc'], [:post, '/accounts/search'], [:get, '/accounts'], [:get, '/accounts/acc_1'], [:post, '/accounts'], [:patch, '/accounts/acc_1'], [:delete, '/accounts/acc_1'], [:post, '/contacts/search'], [:get, '/contacts'], @@ -102,6 +105,12 @@ def rule_for(method, path) = described_class.for(method, path) expect(rule_for(:get, '/users').name).not_to eq(rule_for(:get, '/teams').name) end + it 'keeps a reply and a note out of the window of creating an issue' do + names = [rule_for(:post, '/issues'), rule_for(:post, '/issues/abc/reply'), rule_for(:post, '/issues/abc/note')] + + expect(names.map(&:name).uniq.size).to eq(3) + end + it 'is the same for two records of one endpoint' do expect(rule_for(:get, '/issues/one').name).to eq(rule_for(:get, '/issues/two').name) end