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.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +68 -0
  3. data/README.md +998 -14
  4. data/app/assets/stylesheets/support_desk.css +10 -0
  5. data/app/controllers/support_desk/tickets_controller.rb +23 -1
  6. data/app/helpers/support_desk/engine_helper.rb +16 -0
  7. data/app/views/support_desk/console/tickets/_actions.html.erb +15 -0
  8. data/app/views/support_desk/console/tickets/_assignment.html.erb +16 -2
  9. data/app/views/support_desk/console/tickets/_composer.html.erb +27 -0
  10. data/app/views/support_desk/console/tickets/_context_card.html.erb +28 -0
  11. data/app/views/support_desk/console/tickets/_draft.html.erb +114 -0
  12. data/app/views/support_desk/console/tickets/_message.html.erb +34 -1
  13. data/app/views/support_desk/console/tickets/_ticket_row.html.erb +20 -0
  14. data/app/views/support_desk/console/tickets/_timeline.html.erb +68 -14
  15. data/app/views/support_desk/console/tickets/show.html.erb +5 -0
  16. data/app/views/support_desk/tickets/_human_door.html.erb +24 -0
  17. data/app/views/support_desk/tickets/_ticket_row.html.erb +13 -0
  18. data/config/locales/support_desk.console.en.yml +63 -0
  19. data/config/locales/support_desk.console.es.yml +65 -0
  20. data/config/locales/support_desk.en.yml +15 -0
  21. data/config/locales/support_desk.es.yml +23 -0
  22. data/config/routes.rb +7 -1
  23. data/lib/generators/support_desk/assistant_generator.rb +193 -0
  24. data/lib/generators/support_desk/install_generator.rb +12 -0
  25. data/lib/generators/support_desk/templates/add_assistants_to_support_desk.rb.erb +236 -0
  26. data/lib/generators/support_desk/templates/assistant/service.rb.erb +60 -0
  27. data/lib/generators/support_desk/templates/assistant/turn_job.rb.erb +72 -0
  28. data/lib/generators/support_desk/templates/assistant/turn_job_test.rb.erb +69 -0
  29. data/lib/generators/support_desk/templates/initializer.rb +33 -0
  30. data/lib/generators/support_desk/upgrade_generator.rb +12 -2
  31. data/lib/support_desk/assistant_policy.rb +213 -0
  32. data/lib/support_desk/brief.rb +283 -0
  33. data/lib/support_desk/configuration.rb +552 -2
  34. data/lib/support_desk/console.rb +290 -8
  35. data/lib/support_desk/context_card.rb +10 -1
  36. data/lib/support_desk/doctor.rb +144 -1
  37. data/lib/support_desk/engine.rb +10 -0
  38. data/lib/support_desk/errors.rb +37 -0
  39. data/lib/support_desk/events.rb +12 -5
  40. data/lib/support_desk/macros.rb +14 -1
  41. data/lib/support_desk/models/assistant.rb +153 -0
  42. data/lib/support_desk/models/concerns/requester.rb +18 -0
  43. data/lib/support_desk/models/desk.rb +26 -3
  44. data/lib/support_desk/models/draft.rb +266 -0
  45. data/lib/support_desk/models/event.rb +15 -1
  46. data/lib/support_desk/models/ticket/assistance.rb +675 -0
  47. data/lib/support_desk/models/ticket.rb +334 -37
  48. data/lib/support_desk/outcome.rb +38 -0
  49. data/lib/support_desk/queue.rb +41 -5
  50. data/lib/support_desk/test_helpers.rb +152 -0
  51. data/lib/support_desk/timeline.rb +40 -11
  52. data/lib/support_desk/topic.rb +22 -0
  53. data/lib/support_desk/topic_tree.rb +7 -1
  54. data/lib/support_desk/transcript.rb +237 -0
  55. data/lib/support_desk/version.rb +1 -1
  56. data/lib/support_desk.rb +108 -0
  57. data/lib/tasks/support_desk.rake +46 -0
  58. 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