Repository navigation
feat(datasource): reply to a Pylon issue from Forest on the customer's channel [PRD-1873] #420
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
da54565
8e06c7a
143b144
1be7d81
4359b4c
3617bcb
c4da6bd
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,134 @@ | ||
| 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 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| end | ||
|
|
||
| # An emptied rich-text editor still submits markup such as `<p><br></p>`. | ||
| def blank_html?(body) | ||
| return false if body.match?(/<img\b/i) | ||
|
|
||
| text = CGI.unescapeHTML(body.gsub(/<[^>]*>/, ' ').gsub(/ /i, ' ')) | ||
| text.gsub(/[[:space:]\u200B-\u200D\u2060\uFEFF]/, '').empty? | ||
| end | ||
|
|
||
| def reply(datasource, state, body, context, opts) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| return [:error, state.error] if state.error | ||
| return [:error, no_target_message(state.issue_id, opts)] if state.target.nil? | ||
|
|
||
| payload = { 'body_html' => body, 'message_id' => state.target.message_id } | ||
| if state.target.email? | ||
| excluded = state.target.excluded | ||
| to = ReplyTarget.unique_addresses(Array(context.form_values[FormBuilder::TO_LABEL]), excluded) | ||
| return [:error, 'At least one recipient (To) is required to reply by email.'] if to.empty? | ||
|
|
||
| cc = ReplyTarget.unique_addresses(Array(context.form_values[FormBuilder::CC_LABEL]), | ||
| excluded + to.map(&:downcase)) | ||
| payload['email_info'] = { 'to_emails' => to, 'cc_emails' => cc } | ||
| end | ||
|
|
||
| operator = OperatorLookup.call(datasource, context.caller) if opts[:post_as_operator] | ||
| return [:error, operator.error] if operator&.error | ||
|
|
||
| payload['user_id'] = operator.user['id'] if operator | ||
| write(state.issue_id, 'reply', reply_success(state, payload, operator&.user)) do | ||
| datasource.client.reply_to_issue(state.issue_id, payload) | ||
| end | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| end | ||
|
|
||
| def note(datasource, issue_id, body) | ||
| write(issue_id, 'internal note', "Internal note added to Pylon issue #{issue_id}.") do | ||
| datasource.client.create_issue_note(issue_id, 'body_html' => body) | ||
| end | ||
| end | ||
|
|
||
| # A 4xx is Pylon naming what is wrong, handed over as it is. Anything else | ||
| # may have failed after Pylon delivered the message, so the operator is | ||
| # told to check before sending it again rather than shown a bare failure. | ||
| def write(issue_id, what, success) | ||
| yield | ||
| [:success, success] | ||
| rescue APIError => e | ||
| ForestAdminDatasourcePylon.logger.warn( | ||
| "[forest_admin_datasource_pylon] #{what} on issue #{issue_id} failed: #{e.message}" | ||
| ) | ||
| return [:error, e.message] if (400..499).cover?(e.status.to_i) | ||
|
|
||
| [:error, "Pylon did not confirm the #{what} on issue #{issue_id} (#{e.message}). It may have been " \ | ||
| 'delivered: check the issue in Pylon before sending it again.'] | ||
| end | ||
|
|
||
| def no_target_message(issue_id, opts) | ||
| message = "Pylon issue #{issue_id} has no customer-visible message, so it cannot get a public reply." | ||
| opts[:allow_internal_note] ? "#{message} Post an internal note instead." : message | ||
| end | ||
|
|
||
| def reply_success(state, payload, operator) | ||
| signed = if operator | ||
| "signed by #{[operator["name"], operator["email"]].find { |v| !v.to_s.strip.empty? }}" | ||
| else | ||
| 'signed by the account of the Pylon API token' | ||
| end | ||
| if payload['email_info'] | ||
| return "Reply emailed to #{payload["email_info"]["to_emails"].join(", ")} on Pylon issue " \ | ||
| "#{state.issue_id}, #{signed}." | ||
| end | ||
|
|
||
| "Reply sent via #{state.target.channel_label} on Pylon issue #{state.issue_id}, #{signed}." | ||
| end | ||
| end | ||
| end | ||
| end | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,79 @@ | ||
| module ForestAdminDatasourcePylon | ||
| module Plugins | ||
| class ReplyToIssue | ||
| # Every dynamic property of the form reads the same ThreadLookup state, | ||
| # kept on the render's context so it lives exactly as long as the render. | ||
| # A WeakMap would let the state be collected mid-render. | ||
| module FormBuilder | ||
| FieldType = ForestAdminDatasourceCustomizer::Decorators::Action::Types::FieldType | ||
|
|
||
| TYPE_LABEL = 'Reply type'.freeze | ||
| CHANNEL_LABEL = 'Channel'.freeze | ||
| TO_LABEL = 'To'.freeze | ||
| CC_LABEL = 'CC'.freeze | ||
| MESSAGE_LABEL = 'Message'.freeze | ||
|
|
||
| PUBLIC_REPLY = 'Public reply'.freeze | ||
| INTERNAL_NOTE = 'Internal note'.freeze | ||
|
|
||
| module_function | ||
|
|
||
| def build(datasource, opts) | ||
| ivar = :"@forest_admin_datasource_pylon_reply_state_#{opts.object_id}" | ||
| state = lambda do |context| | ||
| next context.instance_variable_get(ivar) if context.instance_variable_defined?(ivar) | ||
|
|
||
| context.instance_variable_set(ivar, ThreadLookup.call(datasource, context, opts)) | ||
| end | ||
|
|
||
| fields = [] | ||
| fields << type_field(state) if opts[:allow_internal_note] | ||
| fields << channel_field(state, opts) | ||
| fields << recipients_field(TO_LABEL, state, opts, :to) | ||
| fields << recipients_field(CC_LABEL, state, opts, :cc) | ||
| fields << message_field | ||
| end | ||
|
|
||
| def public_reply?(context, opts) | ||
| !opts[:allow_internal_note] || context.get_form_value(TYPE_LABEL) == PUBLIC_REPLY | ||
| end | ||
|
|
||
| def type_field(state) | ||
| { type: FieldType::ENUM, label: TYPE_LABEL, is_required: true, | ||
| enum_values: ->(context) { state.call(context).target ? [PUBLIC_REPLY, INTERNAL_NOTE] : [INTERNAL_NOTE] }, | ||
| default_value: ->(context) { state.call(context).target ? PUBLIC_REPLY : INTERNAL_NOTE }, | ||
| description: 'A public reply reaches the customer; an internal note is seen by your team only.' } | ||
| end | ||
|
|
||
| def channel_field(state, opts) | ||
| { type: FieldType::STRING, label: CHANNEL_LABEL, is_read_only: true, | ||
| if_condition: ->(context) { public_reply?(context, opts) || state.call(context).target.nil? }, | ||
| value: ->(context) { channel_text(state.call(context), opts) }, | ||
| description: 'Pylon delivers the reply on the channel the customer used. It cannot be changed here.' } | ||
| end | ||
|
|
||
| def channel_text(state, opts) | ||
| return state.error if state.error | ||
| return "Reply sent via #{state.target.channel_label}" if state.target | ||
|
|
||
| no_target = 'This issue has no customer-visible message, so it cannot get a public reply.' | ||
| opts[:allow_internal_note] ? "#{no_target} Post an internal note instead." : no_target | ||
| end | ||
|
|
||
| def recipients_field(label, state, opts, side) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| { type: FieldType::STRING_LIST, label: label, is_required: side == :to, | ||
| if_condition: lambda { |context| | ||
| target = state.call(context).target | ||
| public_reply?(context, opts) && !target.nil? && target.email? | ||
| }, | ||
| default_value: ->(context) { state.call(context).target&.public_send(side) || [] }, | ||
| description: 'Pre-filled from the latest email of the conversation, support addresses left out.' } | ||
| end | ||
|
|
||
| def message_field | ||
| { type: FieldType::STRING, label: MESSAGE_LABEL, widget: 'RichText', is_required: true } | ||
| end | ||
| end | ||
| end | ||
| end | ||
| end | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Function with high complexity (count = 6): run [qlty:function-complexity]