support_desk 0.2.0 → 0.3.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/CHANGELOG.md +68 -0
- data/README.md +998 -14
- data/app/assets/stylesheets/support_desk.css +10 -0
- data/app/controllers/support_desk/tickets_controller.rb +23 -1
- data/app/helpers/support_desk/engine_helper.rb +16 -0
- data/app/views/support_desk/console/tickets/_actions.html.erb +15 -0
- data/app/views/support_desk/console/tickets/_assignment.html.erb +16 -2
- data/app/views/support_desk/console/tickets/_composer.html.erb +27 -0
- data/app/views/support_desk/console/tickets/_context_card.html.erb +28 -0
- data/app/views/support_desk/console/tickets/_draft.html.erb +114 -0
- data/app/views/support_desk/console/tickets/_message.html.erb +34 -1
- data/app/views/support_desk/console/tickets/_ticket_row.html.erb +20 -0
- data/app/views/support_desk/console/tickets/_timeline.html.erb +68 -14
- data/app/views/support_desk/console/tickets/show.html.erb +5 -0
- data/app/views/support_desk/tickets/_human_door.html.erb +24 -0
- data/app/views/support_desk/tickets/_ticket_row.html.erb +13 -0
- data/config/locales/support_desk.console.en.yml +63 -0
- data/config/locales/support_desk.console.es.yml +65 -0
- data/config/locales/support_desk.en.yml +15 -0
- data/config/locales/support_desk.es.yml +23 -0
- data/config/routes.rb +7 -1
- data/lib/generators/support_desk/assistant_generator.rb +193 -0
- data/lib/generators/support_desk/install_generator.rb +12 -0
- data/lib/generators/support_desk/templates/add_assistants_to_support_desk.rb.erb +236 -0
- data/lib/generators/support_desk/templates/assistant/service.rb.erb +60 -0
- data/lib/generators/support_desk/templates/assistant/turn_job.rb.erb +72 -0
- data/lib/generators/support_desk/templates/assistant/turn_job_test.rb.erb +69 -0
- data/lib/generators/support_desk/templates/initializer.rb +33 -0
- data/lib/generators/support_desk/upgrade_generator.rb +12 -2
- data/lib/support_desk/assistant_policy.rb +213 -0
- data/lib/support_desk/brief.rb +283 -0
- data/lib/support_desk/configuration.rb +552 -2
- data/lib/support_desk/console.rb +290 -8
- data/lib/support_desk/context_card.rb +10 -1
- data/lib/support_desk/doctor.rb +144 -1
- data/lib/support_desk/engine.rb +10 -0
- data/lib/support_desk/errors.rb +37 -0
- data/lib/support_desk/events.rb +12 -5
- data/lib/support_desk/macros.rb +14 -1
- data/lib/support_desk/models/assistant.rb +153 -0
- data/lib/support_desk/models/concerns/requester.rb +18 -0
- data/lib/support_desk/models/desk.rb +26 -3
- data/lib/support_desk/models/draft.rb +266 -0
- data/lib/support_desk/models/event.rb +15 -1
- data/lib/support_desk/models/ticket/assistance.rb +675 -0
- data/lib/support_desk/models/ticket.rb +334 -37
- data/lib/support_desk/outcome.rb +38 -0
- data/lib/support_desk/queue.rb +41 -5
- data/lib/support_desk/test_helpers.rb +152 -0
- data/lib/support_desk/timeline.rb +40 -11
- data/lib/support_desk/topic.rb +22 -0
- data/lib/support_desk/topic_tree.rb +7 -1
- data/lib/support_desk/transcript.rb +237 -0
- data/lib/support_desk/version.rb +1 -1
- data/lib/support_desk.rb +108 -0
- data/lib/tasks/support_desk.rake +46 -0
- metadata +30 -8
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "active_support/core_ext/module/delegation"
|
|
4
4
|
require_relative "topic_tree"
|
|
5
|
+
require_relative "assistant_policy"
|
|
5
6
|
|
|
6
7
|
module SupportDesk
|
|
7
8
|
# Everything a desk decides, with defaults that already work.
|
|
@@ -75,7 +76,8 @@ module SupportDesk
|
|
|
75
76
|
inbox_entry: :always,
|
|
76
77
|
routing: :manual,
|
|
77
78
|
mirror_replies_by_email: :when_away,
|
|
78
|
-
auto_close_after: nil
|
|
79
|
+
auto_close_after: nil,
|
|
80
|
+
assistant: nil
|
|
79
81
|
}.freeze
|
|
80
82
|
|
|
81
83
|
attr_reader :key, :fallback
|
|
@@ -422,6 +424,45 @@ module SupportDesk
|
|
|
422
424
|
@settings[:auto_close_after] = ensure_duration(value, "auto_close_after")
|
|
423
425
|
end
|
|
424
426
|
|
|
427
|
+
# --- The assistant --------------------------------------------------------
|
|
428
|
+
|
|
429
|
+
# The key of the assistant that works this desk, as this desk states
|
|
430
|
+
# it. nil means "nothing stated" when the desk doesn't own the setting,
|
|
431
|
+
# and "explicitly nobody" when it does — which is why #assistant_key,
|
|
432
|
+
# not this, is what everything reads.
|
|
433
|
+
def assistant = read(:assistant)
|
|
434
|
+
|
|
435
|
+
# `desk.assistant = :rose` binds one; `desk.assistant = nil` states
|
|
436
|
+
# that THIS desk has none, which is a different thing from inheriting
|
|
437
|
+
# the installation's default.
|
|
438
|
+
def assistant=(value)
|
|
439
|
+
if value.nil?
|
|
440
|
+
@settings[:assistant] = nil
|
|
441
|
+
return
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
unless value.is_a?(Symbol) || value.is_a?(String)
|
|
445
|
+
raise ConfigurationError,
|
|
446
|
+
"desk #{key}: assistant must be a configured assistant's key (a Symbol) or nil, " \
|
|
447
|
+
"got #{value.inspect}"
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
@settings[:assistant] = value.to_sym
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
# Which assistant answers here: this desk's own choice (including an
|
|
454
|
+
# explicit "none"), else the installation's default, else the only
|
|
455
|
+
# assistant configured, else nobody.
|
|
456
|
+
#
|
|
457
|
+
# It reaches for `SupportDesk.config` rather than walking the fallback
|
|
458
|
+
# chain because `default_assistant` is an INSTALLATION setting, not a
|
|
459
|
+
# desk one — there is nowhere else for it to live.
|
|
460
|
+
def assistant_key
|
|
461
|
+
return read(:assistant) if own?(:assistant)
|
|
462
|
+
|
|
463
|
+
SupportDesk.config.default_assistant_key
|
|
464
|
+
end
|
|
465
|
+
|
|
425
466
|
# --- Internals ------------------------------------------------------------
|
|
426
467
|
|
|
427
468
|
def read(name) # :nodoc:
|
|
@@ -519,13 +560,406 @@ module SupportDesk
|
|
|
519
560
|
end
|
|
520
561
|
end
|
|
521
562
|
|
|
563
|
+
# Everything ONE assistant is, as configuration. The record
|
|
564
|
+
# (SupportDesk::Assistant) is an identity and a kill switch; every rule
|
|
565
|
+
# she works under is here, in code, so a policy change is a deploy and a
|
|
566
|
+
# diff rather than a row somebody edited.
|
|
567
|
+
#
|
|
568
|
+
# config.assistant :rose do |rose|
|
|
569
|
+
# rose.name = "Rose"
|
|
570
|
+
# rose.autonomy = :draft
|
|
571
|
+
# rose.disclosure = :signature_and_notice
|
|
572
|
+
# end
|
|
573
|
+
#
|
|
574
|
+
# Every setter validates on assignment, like the rest of the gem. One
|
|
575
|
+
# setting deliberately has NO default: `disclosure`. Whether a customer
|
|
576
|
+
# is told they are talking to a machine is not a decision this gem gets
|
|
577
|
+
# to make quietly on a host's behalf, so omitting it fails boot (12 #22).
|
|
578
|
+
class AssistantConfiguration
|
|
579
|
+
DISCLOSURE_MODES = %i[signature_and_notice signature notice none].freeze
|
|
580
|
+
|
|
581
|
+
# The system lines an assistant can post. All three take the same
|
|
582
|
+
# interpolations and are read the same way.
|
|
583
|
+
LINE_SETTINGS = %i[hand_off_line human_requested_line disclosure_line].freeze
|
|
584
|
+
|
|
585
|
+
# The sample a static line is interpolated against the moment it is
|
|
586
|
+
# assigned, so a typo'd %{nam} is a boot failure and not a 3am
|
|
587
|
+
# exception in the middle of a hand-off.
|
|
588
|
+
LINE_INTERPOLATIONS = { name: "…", desk: "…", reply_within: "…" }.freeze
|
|
589
|
+
|
|
590
|
+
DEFAULTS = {
|
|
591
|
+
name: nil,
|
|
592
|
+
avatar: nil,
|
|
593
|
+
autonomy: :draft,
|
|
594
|
+
disclosure: nil,
|
|
595
|
+
max_turns: 6,
|
|
596
|
+
responds_within: 3 * 60,
|
|
597
|
+
may_open_conversations: false,
|
|
598
|
+
hand_off_line: :"support_desk.system.handed_off_to_humans",
|
|
599
|
+
human_requested_line: :"support_desk.system.human_requested",
|
|
600
|
+
disclosure_line: :"support_desk.system.assistant_disclosure"
|
|
601
|
+
}.freeze
|
|
602
|
+
|
|
603
|
+
attr_reader :key
|
|
604
|
+
|
|
605
|
+
# A fresh assistant, everything at its documented default — except
|
|
606
|
+
# `disclosure`, which has none.
|
|
607
|
+
def initialize(key)
|
|
608
|
+
@key = key.to_sym
|
|
609
|
+
@settings = {}
|
|
610
|
+
@hand_off_when = nil
|
|
611
|
+
@cap = nil
|
|
612
|
+
end
|
|
613
|
+
|
|
614
|
+
# --- Identity -------------------------------------------------------------
|
|
615
|
+
|
|
616
|
+
# What the requester sees, before disclosure decorates it.
|
|
617
|
+
def name = read(:name) || key.to_s.humanize
|
|
618
|
+
|
|
619
|
+
# nil resets the name to the humanized key; a blank string is a
|
|
620
|
+
# mistake, not an intention.
|
|
621
|
+
def name=(value)
|
|
622
|
+
return @settings.delete(:name) if value.nil?
|
|
623
|
+
|
|
624
|
+
string = value.to_s
|
|
625
|
+
raise ConfigurationError, "assistant #{key}: name can't be blank" if string.strip.empty?
|
|
626
|
+
|
|
627
|
+
@settings[:name] = string
|
|
628
|
+
end
|
|
629
|
+
|
|
630
|
+
# An asset path, a URL, or ->(assistant) { … }. A brand mark, not a
|
|
631
|
+
# face — see the README.
|
|
632
|
+
def avatar = read(:avatar)
|
|
633
|
+
|
|
634
|
+
# Set it, validating on assignment (see the reader above).
|
|
635
|
+
def avatar=(value)
|
|
636
|
+
unless value.nil? || value.is_a?(String) || value.respond_to?(:call)
|
|
637
|
+
raise ConfigurationError,
|
|
638
|
+
"assistant #{key}: avatar must be a String, a callable, or nil, got #{value.inspect}"
|
|
639
|
+
end
|
|
640
|
+
|
|
641
|
+
@settings[:avatar] = value
|
|
642
|
+
end
|
|
643
|
+
|
|
644
|
+
# --- What she may do ------------------------------------------------------
|
|
645
|
+
|
|
646
|
+
# The GLOBAL ceiling: the most this assistant may ever produce. Topics
|
|
647
|
+
# only cap it DOWN, so promoting a desk means raising this AND capping
|
|
648
|
+
# every topic that has to stay human-sent.
|
|
649
|
+
def autonomy = read(:autonomy)
|
|
650
|
+
|
|
651
|
+
# Set it, validating on assignment (see the reader above).
|
|
652
|
+
def autonomy=(value)
|
|
653
|
+
@settings[:autonomy] = ensure_level(value, "autonomy")
|
|
654
|
+
end
|
|
655
|
+
|
|
656
|
+
# How many times she may speak in one case. nil is unlimited, which
|
|
657
|
+
# `doctor` warns about: a loop with no bound is a loop.
|
|
658
|
+
def max_turns = read(:max_turns)
|
|
659
|
+
|
|
660
|
+
# Set it, validating on assignment (see the reader above).
|
|
661
|
+
def max_turns=(value)
|
|
662
|
+
unless value.nil? || (value.is_a?(Integer) && value.positive?)
|
|
663
|
+
raise ConfigurationError,
|
|
664
|
+
"assistant #{key}: max_turns must be a positive Integer or nil, got #{value.inspect}"
|
|
665
|
+
end
|
|
666
|
+
|
|
667
|
+
@settings[:max_turns] = value
|
|
668
|
+
end
|
|
669
|
+
|
|
670
|
+
# How long a case she holds may wait before the sweep releases her seat
|
|
671
|
+
# and asks for a person. nil disables that safety net, and `doctor`
|
|
672
|
+
# says so.
|
|
673
|
+
def responds_within = duration(read(:responds_within))
|
|
674
|
+
|
|
675
|
+
# Set it, validating on assignment (see the reader above).
|
|
676
|
+
def responds_within=(value)
|
|
677
|
+
unless value.nil? || value.is_a?(ActiveSupport::Duration) || value.is_a?(Numeric)
|
|
678
|
+
raise ConfigurationError,
|
|
679
|
+
"assistant #{key}: responds_within must be a duration (e.g. 3.minutes) or nil, " \
|
|
680
|
+
"got #{value.inspect}"
|
|
681
|
+
end
|
|
682
|
+
|
|
683
|
+
@settings[:responds_within] = value
|
|
684
|
+
end
|
|
685
|
+
|
|
686
|
+
# Whether she may open a case nobody asked for (outreach).
|
|
687
|
+
def may_open_conversations = read(:may_open_conversations)
|
|
688
|
+
|
|
689
|
+
# The same question, spelled as a predicate.
|
|
690
|
+
def may_open_conversations? = !!read(:may_open_conversations)
|
|
691
|
+
|
|
692
|
+
# Set it, validating on assignment. Strict booleans: a truthy string
|
|
693
|
+
# here would be somebody meaning `false`.
|
|
694
|
+
def may_open_conversations=(value)
|
|
695
|
+
unless [ true, false ].include?(value)
|
|
696
|
+
raise ConfigurationError,
|
|
697
|
+
"assistant #{key}: may_open_conversations must be true or false, got #{value.inspect}"
|
|
698
|
+
end
|
|
699
|
+
|
|
700
|
+
@settings[:may_open_conversations] = value
|
|
701
|
+
end
|
|
702
|
+
|
|
703
|
+
# --- Disclosure -----------------------------------------------------------
|
|
704
|
+
|
|
705
|
+
# How the requester is told they are talking to a machine:
|
|
706
|
+
#
|
|
707
|
+
# :signature_and_notice her name signs every message AND a notice
|
|
708
|
+
# opens the conversation
|
|
709
|
+
# :signature her name signs every message
|
|
710
|
+
# :notice a notice opens the conversation; the
|
|
711
|
+
# messages themselves are the desk's voice
|
|
712
|
+
# :none nothing is said
|
|
713
|
+
#
|
|
714
|
+
# Required. There is no default because there is no default answer.
|
|
715
|
+
def disclosure = read(:disclosure)
|
|
716
|
+
|
|
717
|
+
# Set it, validating on assignment. nil is refused: `:none` is how you
|
|
718
|
+
# say "nothing", and you say it on purpose.
|
|
719
|
+
def disclosure=(value)
|
|
720
|
+
if value.nil?
|
|
721
|
+
raise ConfigurationError,
|
|
722
|
+
"assistant #{key}: disclosure is required — one of " \
|
|
723
|
+
"#{DISCLOSURE_MODES.map(&:inspect).join(", ")}. `:none` is the explicit way to say nothing."
|
|
724
|
+
end
|
|
725
|
+
|
|
726
|
+
@settings[:disclosure] = ensure_one_of(value, DISCLOSURE_MODES, "disclosure")
|
|
727
|
+
end
|
|
728
|
+
|
|
729
|
+
# Whether her messages carry her name.
|
|
730
|
+
def signs? = %i[signature_and_notice signature].include?(disclosure)
|
|
731
|
+
|
|
732
|
+
# Whether the conversation opens with a notice about her.
|
|
733
|
+
def notice? = %i[signature_and_notice notice].include?(disclosure)
|
|
734
|
+
|
|
735
|
+
# Whether anything at all is said.
|
|
736
|
+
def disclosed? = !disclosure.nil? && disclosure != :none
|
|
737
|
+
|
|
738
|
+
# --- Lines ----------------------------------------------------------------
|
|
739
|
+
|
|
740
|
+
# What she posts when she hands the case to a person.
|
|
741
|
+
def hand_off_line(&block)
|
|
742
|
+
return @settings[:hand_off_line] = block if block
|
|
743
|
+
|
|
744
|
+
read(:hand_off_line)
|
|
745
|
+
end
|
|
746
|
+
|
|
747
|
+
# Set it, validating on assignment (see the reader above).
|
|
748
|
+
def hand_off_line=(value)
|
|
749
|
+
@settings[:hand_off_line] = ensure_line(value, "hand_off_line")
|
|
750
|
+
end
|
|
751
|
+
|
|
752
|
+
# What the desk posts when the REQUESTER asks for a person.
|
|
753
|
+
def human_requested_line(&block)
|
|
754
|
+
return @settings[:human_requested_line] = block if block
|
|
755
|
+
|
|
756
|
+
read(:human_requested_line)
|
|
757
|
+
end
|
|
758
|
+
|
|
759
|
+
# Set it, validating on assignment (see the reader above).
|
|
760
|
+
def human_requested_line=(value)
|
|
761
|
+
@settings[:human_requested_line] = ensure_line(value, "human_requested_line")
|
|
762
|
+
end
|
|
763
|
+
|
|
764
|
+
# The notice a `:notice` mode opens the conversation with.
|
|
765
|
+
def disclosure_line(&block)
|
|
766
|
+
return @settings[:disclosure_line] = block if block
|
|
767
|
+
|
|
768
|
+
read(:disclosure_line)
|
|
769
|
+
end
|
|
770
|
+
|
|
771
|
+
# Set it, validating on assignment (see the reader above).
|
|
772
|
+
def disclosure_line=(value)
|
|
773
|
+
@settings[:disclosure_line] = ensure_line(value, "disclosure_line")
|
|
774
|
+
end
|
|
775
|
+
|
|
776
|
+
# --- Host hooks -----------------------------------------------------------
|
|
777
|
+
|
|
778
|
+
# ->(ticket, message) { true } — run on every requester message, before
|
|
779
|
+
# the model, and FAILS CLOSED: anything but true, false or nil (a
|
|
780
|
+
# raise included) is reported and the case is handed to a person.
|
|
781
|
+
# "Somebody typed 'quiero hablar con una persona'" must never depend on
|
|
782
|
+
# a model answering.
|
|
783
|
+
def hand_off_when(&block)
|
|
784
|
+
return @hand_off_when = block if block
|
|
785
|
+
|
|
786
|
+
@hand_off_when
|
|
787
|
+
end
|
|
788
|
+
|
|
789
|
+
# Set it, validating on assignment (see the reader above).
|
|
790
|
+
def hand_off_when=(value)
|
|
791
|
+
@hand_off_when = value.nil? ? nil : ensure_callable(value, "hand_off_when")
|
|
792
|
+
end
|
|
793
|
+
|
|
794
|
+
# ->(ticket) { :draft } — a per-case ceiling the host computes (a VIP,
|
|
795
|
+
# a banned requester, a case about money). Returns a level, or nil for
|
|
796
|
+
# "no opinion".
|
|
797
|
+
def cap(&block)
|
|
798
|
+
return @cap = block if block
|
|
799
|
+
|
|
800
|
+
@cap
|
|
801
|
+
end
|
|
802
|
+
|
|
803
|
+
# Set it, validating on assignment (see the reader above).
|
|
804
|
+
def cap=(value)
|
|
805
|
+
@cap = value.nil? ? nil : ensure_callable(value, "cap")
|
|
806
|
+
end
|
|
807
|
+
|
|
808
|
+
# --- Reading the lines ----------------------------------------------------
|
|
809
|
+
|
|
810
|
+
# What to post for THIS ticket, resolved and interpolated in the
|
|
811
|
+
# current locale. nil or blank means post nothing.
|
|
812
|
+
#
|
|
813
|
+
# A Symbol whose `_with_promise` variant exists is used when the desk
|
|
814
|
+
# promises an answer time, and the plain one when it doesn't — the
|
|
815
|
+
# gem's own copies come in both shapes, so a hand-off never invents a
|
|
816
|
+
# duration nobody promised.
|
|
817
|
+
def line_for(setting, ticket)
|
|
818
|
+
value = public_send(setting)
|
|
819
|
+
return nil if value.nil?
|
|
820
|
+
|
|
821
|
+
interpolations = line_interpolations(ticket)
|
|
822
|
+
line = case value
|
|
823
|
+
when Symbol then I18n.t(promised_key(value, interpolations), **interpolations, raise: true)
|
|
824
|
+
when String then interpolate_line(value, interpolations, setting.to_s)
|
|
825
|
+
else value.call(ticket)
|
|
826
|
+
end
|
|
827
|
+
return nil if line.nil?
|
|
828
|
+
|
|
829
|
+
unless line.is_a?(String)
|
|
830
|
+
raise ConfigurationError,
|
|
831
|
+
"assistant #{key}: a #{setting} block must return a String or nil, got #{line.inspect}"
|
|
832
|
+
end
|
|
833
|
+
|
|
834
|
+
line
|
|
835
|
+
end
|
|
836
|
+
|
|
837
|
+
# What's wrong with this assistant's lines, as sentences — what
|
|
838
|
+
# `doctor` reports. Mirrors DeskConfiguration#opening_line_problems: a
|
|
839
|
+
# String is interpolated against the sample, a Symbol has to exist in
|
|
840
|
+
# the current locale, and a block is left alone, because it needs a
|
|
841
|
+
# ticket and running a host's callback as a diagnostic is not a
|
|
842
|
+
# diagnostic.
|
|
843
|
+
def line_problems # :nodoc:
|
|
844
|
+
LINE_SETTINGS.filter_map do |setting|
|
|
845
|
+
value = public_send(setting)
|
|
846
|
+
next if value.nil? || value.respond_to?(:call)
|
|
847
|
+
|
|
848
|
+
if value.is_a?(Symbol)
|
|
849
|
+
next if I18n.exists?(value)
|
|
850
|
+
|
|
851
|
+
"assistant #{key}: #{setting} names #{value.inspect}, which has no #{I18n.locale} translation"
|
|
852
|
+
else
|
|
853
|
+
begin
|
|
854
|
+
interpolate_line(value, LINE_INTERPOLATIONS, setting.to_s)
|
|
855
|
+
nil
|
|
856
|
+
rescue ConfigurationError => e
|
|
857
|
+
e.message
|
|
858
|
+
end
|
|
859
|
+
end
|
|
860
|
+
end
|
|
861
|
+
end
|
|
862
|
+
|
|
863
|
+
# --- Internals ------------------------------------------------------------
|
|
864
|
+
|
|
865
|
+
def read(name) # :nodoc:
|
|
866
|
+
return @settings[name] if @settings.key?(name)
|
|
867
|
+
|
|
868
|
+
DEFAULTS[name]
|
|
869
|
+
end
|
|
870
|
+
|
|
871
|
+
# Whether this assistant states the setting herself (tests).
|
|
872
|
+
def own?(name) = @settings.key?(name) # :nodoc:
|
|
873
|
+
|
|
874
|
+
# Forget a setting so it goes back to its default (tests).
|
|
875
|
+
def reset_setting(name) # :nodoc:
|
|
876
|
+
@settings.delete(name)
|
|
877
|
+
end
|
|
878
|
+
|
|
879
|
+
# The assistant, in one line.
|
|
880
|
+
def inspect
|
|
881
|
+
"#<SupportDesk::Configuration::AssistantConfiguration #{key} #{autonomy} #{disclosure.inspect}>"
|
|
882
|
+
end
|
|
883
|
+
|
|
884
|
+
private
|
|
885
|
+
|
|
886
|
+
def duration(value)
|
|
887
|
+
return nil if value.nil?
|
|
888
|
+
return value if value.is_a?(ActiveSupport::Duration)
|
|
889
|
+
|
|
890
|
+
ActiveSupport::Duration.build(value.to_i)
|
|
891
|
+
end
|
|
892
|
+
|
|
893
|
+
def ensure_level(value, name)
|
|
894
|
+
ensure_one_of(value, AssistantPolicy::LEVELS, name)
|
|
895
|
+
end
|
|
896
|
+
|
|
897
|
+
def ensure_one_of(value, allowed, name)
|
|
898
|
+
symbol = value.respond_to?(:to_sym) ? value.to_sym : value
|
|
899
|
+
unless allowed.include?(symbol)
|
|
900
|
+
raise ConfigurationError,
|
|
901
|
+
"assistant #{key}: #{name} must be one of #{allowed.map(&:inspect).join(", ")}, " \
|
|
902
|
+
"got #{value.inspect}"
|
|
903
|
+
end
|
|
904
|
+
|
|
905
|
+
symbol
|
|
906
|
+
end
|
|
907
|
+
|
|
908
|
+
def ensure_callable(value, name)
|
|
909
|
+
unless value.respond_to?(:call)
|
|
910
|
+
raise ConfigurationError,
|
|
911
|
+
"assistant #{key}: #{name} must respond to #call (a proc/lambda), got #{value.inspect}"
|
|
912
|
+
end
|
|
913
|
+
|
|
914
|
+
value
|
|
915
|
+
end
|
|
916
|
+
|
|
917
|
+
def ensure_line(value, name)
|
|
918
|
+
return value if value.nil? || value.is_a?(Symbol) || value.respond_to?(:call)
|
|
919
|
+
if value.is_a?(String)
|
|
920
|
+
interpolate_line(value, LINE_INTERPOLATIONS, name)
|
|
921
|
+
return value
|
|
922
|
+
end
|
|
923
|
+
|
|
924
|
+
raise ConfigurationError,
|
|
925
|
+
"assistant #{key}: #{name} must be a String, an I18n key (Symbol), a block, or nil, " \
|
|
926
|
+
"got #{value.inspect}"
|
|
927
|
+
end
|
|
928
|
+
|
|
929
|
+
# Named interpolation, NOT String#%: "100% seguro" is ordinary copy in
|
|
930
|
+
# any language, and `%` would read that as a format directive.
|
|
931
|
+
def interpolate_line(line, interpolations, name)
|
|
932
|
+
I18n.interpolate(line, interpolations)
|
|
933
|
+
rescue KeyError, ArgumentError => e
|
|
934
|
+
raise ConfigurationError,
|
|
935
|
+
"assistant #{key}: #{name} can't be interpolated (#{e.class}: #{e.message}). The placeholders " \
|
|
936
|
+
"it can use are #{LINE_INTERPOLATIONS.keys.map { |name| "%{#{name}}" }.join(", ")}."
|
|
937
|
+
end
|
|
938
|
+
|
|
939
|
+
# The "… en menos de 24 h" variant of a key, when the desk promises a
|
|
940
|
+
# time AND that variant exists. Everything else falls back to the key
|
|
941
|
+
# as written, a host's own included.
|
|
942
|
+
def promised_key(symbol, interpolations)
|
|
943
|
+
return symbol if interpolations[:reply_within].nil?
|
|
944
|
+
|
|
945
|
+
promised = :"#{symbol}_with_promise"
|
|
946
|
+
I18n.exists?(promised) ? promised : symbol
|
|
947
|
+
end
|
|
948
|
+
|
|
949
|
+
def line_interpolations(ticket)
|
|
950
|
+
reply_within = ticket.desk_config.reply_within
|
|
951
|
+
{ name: name, desk: ticket.desk.name,
|
|
952
|
+
reply_within: (SupportDesk.humanize_duration(reply_within) if reply_within) }
|
|
953
|
+
end
|
|
954
|
+
end
|
|
955
|
+
|
|
522
956
|
# Settings that belong to a desk rather than the installation. The
|
|
523
957
|
# top-level accessors forward to the `:default` desk, which is also what
|
|
524
958
|
# every other desk falls back to.
|
|
525
959
|
DESK_SETTINGS = %i[
|
|
526
960
|
name avatar email opening_line opening_line_from_support find_requester reply_policy
|
|
527
961
|
announce_assignments closed_tickets reply_within at_risk_after open_rate_limit max_open_tickets
|
|
528
|
-
inbox_entry routing mirror_replies_by_email auto_close_after
|
|
962
|
+
inbox_entry routing mirror_replies_by_email auto_close_after assistant
|
|
529
963
|
].freeze
|
|
530
964
|
|
|
531
965
|
delegate(*DESK_SETTINGS, *DESK_SETTINGS.map { |setting| :"#{setting}=" }, to: :default_desk)
|
|
@@ -578,6 +1012,8 @@ module SupportDesk
|
|
|
578
1012
|
@authorize_console = nil
|
|
579
1013
|
|
|
580
1014
|
@desks = { default: DeskConfiguration.new(:default) }
|
|
1015
|
+
@assistants = {}
|
|
1016
|
+
@default_assistant = nil
|
|
581
1017
|
@warnings = []
|
|
582
1018
|
end
|
|
583
1019
|
|
|
@@ -674,6 +1110,73 @@ module SupportDesk
|
|
|
674
1110
|
SupportDesk.on(event, &block)
|
|
675
1111
|
end
|
|
676
1112
|
|
|
1113
|
+
# --- Assistants ---------------------------------------------------------------
|
|
1114
|
+
|
|
1115
|
+
# Declare or reconfigure an assistant:
|
|
1116
|
+
#
|
|
1117
|
+
# config.assistant :rose do |rose|
|
|
1118
|
+
# rose.autonomy = :draft
|
|
1119
|
+
# rose.disclosure = :signature
|
|
1120
|
+
# end
|
|
1121
|
+
#
|
|
1122
|
+
# Without a block it READS one, and an unknown key is a ConfigurationError
|
|
1123
|
+
# rather than nil — a typo in a desk binding should fail at boot, not
|
|
1124
|
+
# leave a desk quietly unassisted. This never doubles as the default
|
|
1125
|
+
# getter: `default_assistant` is its own setting.
|
|
1126
|
+
#
|
|
1127
|
+
# (The `assistant` in DESK_SETTINGS delegates the SETTER to the default
|
|
1128
|
+
# desk, so `config.assistant = :rose` binds the default desk; this reader
|
|
1129
|
+
# is defined afterwards and wins, which is the intent.)
|
|
1130
|
+
def assistant(key, &block)
|
|
1131
|
+
key = key.to_sym
|
|
1132
|
+
if block
|
|
1133
|
+
configuration = @assistants[key] ||= AssistantConfiguration.new(key)
|
|
1134
|
+
block.call(configuration)
|
|
1135
|
+
return configuration
|
|
1136
|
+
end
|
|
1137
|
+
|
|
1138
|
+
@assistants.fetch(key) do
|
|
1139
|
+
raise ConfigurationError,
|
|
1140
|
+
"no assistant #{key.inspect} is configured" \
|
|
1141
|
+
"#{" (known: #{@assistants.keys.map(&:inspect).join(", ")})" if @assistants.any?}"
|
|
1142
|
+
end
|
|
1143
|
+
end
|
|
1144
|
+
|
|
1145
|
+
# Every configured assistant, keyed by key.
|
|
1146
|
+
def assistants = @assistants
|
|
1147
|
+
|
|
1148
|
+
def assistant?(key) = @assistants.key?(key.to_sym)
|
|
1149
|
+
|
|
1150
|
+
# The installation's default assistant — the one a desk that says nothing
|
|
1151
|
+
# gets. Required from the moment there are two.
|
|
1152
|
+
attr_reader :default_assistant
|
|
1153
|
+
|
|
1154
|
+
# Set it. Existence is checked by `validate!`, at the end of the
|
|
1155
|
+
# configure block, so the order of the initializer never matters.
|
|
1156
|
+
def default_assistant=(value)
|
|
1157
|
+
if value.nil?
|
|
1158
|
+
@default_assistant = nil
|
|
1159
|
+
return
|
|
1160
|
+
end
|
|
1161
|
+
|
|
1162
|
+
unless value.is_a?(Symbol) || value.is_a?(String)
|
|
1163
|
+
raise ConfigurationError,
|
|
1164
|
+
"default_assistant must be a configured assistant's key (a Symbol) or nil, got #{value.inspect}"
|
|
1165
|
+
end
|
|
1166
|
+
|
|
1167
|
+
@default_assistant = value.to_sym
|
|
1168
|
+
end
|
|
1169
|
+
|
|
1170
|
+
# The key every desk falls back to: the stated default, or the single
|
|
1171
|
+
# configured assistant when there is exactly one (the DX case — one
|
|
1172
|
+
# assistant, one desk, nothing to say twice).
|
|
1173
|
+
def default_assistant_key # :nodoc:
|
|
1174
|
+
return @default_assistant if @default_assistant
|
|
1175
|
+
return @assistants.keys.first if @assistants.size == 1
|
|
1176
|
+
|
|
1177
|
+
nil
|
|
1178
|
+
end
|
|
1179
|
+
|
|
677
1180
|
# --- Validation -------------------------------------------------------------
|
|
678
1181
|
|
|
679
1182
|
# Cross-field validation, run at the end of `SupportDesk.configure`.
|
|
@@ -688,6 +1191,7 @@ module SupportDesk
|
|
|
688
1191
|
"reply_within (#{desk.reply_within.inspect}) — a ticket can't breach before it's at risk"
|
|
689
1192
|
end
|
|
690
1193
|
|
|
1194
|
+
validate_assistants!
|
|
691
1195
|
true
|
|
692
1196
|
end
|
|
693
1197
|
|
|
@@ -742,6 +1246,52 @@ module SupportDesk
|
|
|
742
1246
|
|
|
743
1247
|
private
|
|
744
1248
|
|
|
1249
|
+
# The four rules that can't be checked one setter at a time: an
|
|
1250
|
+
# assistant with no disclosure, a desk pointing at an assistant nobody
|
|
1251
|
+
# declared, a default pointing nowhere, and two assistants with no way to
|
|
1252
|
+
# tell which one a desk gets.
|
|
1253
|
+
def validate_assistants!
|
|
1254
|
+
@assistants.each_value do |assistant|
|
|
1255
|
+
next unless assistant.disclosure.nil?
|
|
1256
|
+
|
|
1257
|
+
raise ConfigurationError,
|
|
1258
|
+
"assistant #{assistant.key}: disclosure is required — one of " \
|
|
1259
|
+
"#{AssistantConfiguration::DISCLOSURE_MODES.map(&:inspect).join(", ")}. " \
|
|
1260
|
+
"`:none` is the explicit way to say nothing."
|
|
1261
|
+
end
|
|
1262
|
+
|
|
1263
|
+
@desks.each_value do |desk|
|
|
1264
|
+
key = desk.assistant
|
|
1265
|
+
next if key.nil? || @assistants.key?(key)
|
|
1266
|
+
|
|
1267
|
+
raise ConfigurationError,
|
|
1268
|
+
"desk #{desk.key}: assistant #{key.inspect} isn't configured. Declare it with " \
|
|
1269
|
+
"`config.assistant #{key.inspect} do |assistant| … end`, or set the desk's assistant to nil."
|
|
1270
|
+
end
|
|
1271
|
+
|
|
1272
|
+
if @default_assistant && !@assistants.key?(@default_assistant)
|
|
1273
|
+
raise ConfigurationError,
|
|
1274
|
+
"default_assistant is #{@default_assistant.inspect}, which isn't configured. Declare it with " \
|
|
1275
|
+
"`config.assistant #{@default_assistant.inspect} do |assistant| … end`."
|
|
1276
|
+
end
|
|
1277
|
+
|
|
1278
|
+
return true if @assistants.size <= 1 || @default_assistant
|
|
1279
|
+
|
|
1280
|
+
# Two assistants and no default is only ambiguous for a desk that
|
|
1281
|
+
# states nothing — a desk bound to one (or explicitly to none) has its
|
|
1282
|
+
# answer, which is the other half of the sentence this used to raise
|
|
1283
|
+
# even when somebody had done exactly what it asked for.
|
|
1284
|
+
unbound = @desks.each_value.reject { |desk| desk.own?(:assistant) }
|
|
1285
|
+
return true if unbound.empty?
|
|
1286
|
+
|
|
1287
|
+
raise ConfigurationError,
|
|
1288
|
+
"#{@assistants.size} assistants are configured (#{@assistants.keys.map(&:inspect).join(", ")}) " \
|
|
1289
|
+
"and nothing says which one desk#{"s" if unbound.size > 1} " \
|
|
1290
|
+
"#{unbound.map { |desk| desk.key.inspect }.join(", ")} get#{"s" if unbound.size == 1}. Set " \
|
|
1291
|
+
"`config.default_assistant`, or bind them with " \
|
|
1292
|
+
"`config.desk(:key) { |desk| desk.assistant = :… }`."
|
|
1293
|
+
end
|
|
1294
|
+
|
|
745
1295
|
# `config.agents { … }` has to hand back something a desk can iterate.
|
|
746
1296
|
# Resolving it costs nothing at boot — a relation is lazy — and a block
|
|
747
1297
|
# that returns 42, or raises, is a configuration mistake, not a 3am
|