support_desk 0.1.3 → 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 (72) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +126 -0
  3. data/README.md +1101 -22
  4. data/Rakefile +16 -0
  5. data/app/assets/stylesheets/support_desk.css +10 -0
  6. data/app/controllers/support_desk/tickets_controller.rb +28 -4
  7. data/app/helpers/support_desk/engine_helper.rb +23 -1
  8. data/app/views/support_desk/console/tickets/_actions.html.erb +15 -0
  9. data/app/views/support_desk/console/tickets/_assignment.html.erb +16 -2
  10. data/app/views/support_desk/console/tickets/_composer.html.erb +27 -0
  11. data/app/views/support_desk/console/tickets/_context_card.html.erb +33 -0
  12. data/app/views/support_desk/console/tickets/_draft.html.erb +114 -0
  13. data/app/views/support_desk/console/tickets/_message.html.erb +34 -1
  14. data/app/views/support_desk/console/tickets/_new_conversation_form.html.erb +91 -0
  15. data/app/views/support_desk/console/tickets/_ticket_row.html.erb +28 -0
  16. data/app/views/support_desk/console/tickets/_timeline.html.erb +68 -14
  17. data/app/views/support_desk/console/tickets/index.html.erb +14 -2
  18. data/app/views/support_desk/console/tickets/new.html.erb +25 -0
  19. data/app/views/support_desk/console/tickets/show.html.erb +5 -0
  20. data/app/views/support_desk/tickets/_human_door.html.erb +24 -0
  21. data/app/views/support_desk/tickets/_ticket_row.html.erb +13 -0
  22. data/config/console_routes.rb +1 -1
  23. data/config/locales/support_desk.console.en.yml +89 -0
  24. data/config/locales/support_desk.console.es.yml +91 -0
  25. data/config/locales/support_desk.en.yml +18 -0
  26. data/config/locales/support_desk.es.yml +29 -0
  27. data/config/routes.rb +7 -1
  28. data/lib/generators/support_desk/assistant_generator.rb +193 -0
  29. data/lib/generators/support_desk/console_generator.rb +1 -1
  30. data/lib/generators/support_desk/install_generator.rb +26 -0
  31. data/lib/generators/support_desk/templates/add_assistants_to_support_desk.rb.erb +236 -0
  32. data/lib/generators/support_desk/templates/add_opened_by_to_support_desk_tickets.rb.erb +79 -0
  33. data/lib/generators/support_desk/templates/assistant/service.rb.erb +60 -0
  34. data/lib/generators/support_desk/templates/assistant/turn_job.rb.erb +72 -0
  35. data/lib/generators/support_desk/templates/assistant/turn_job_test.rb.erb +69 -0
  36. data/lib/generators/support_desk/templates/console/controller.rb.erb +1 -1
  37. data/lib/generators/support_desk/templates/initializer.rb +63 -0
  38. data/lib/generators/support_desk/upgrade_generator.rb +66 -0
  39. data/lib/support_desk/assistant_policy.rb +213 -0
  40. data/lib/support_desk/brief.rb +283 -0
  41. data/lib/support_desk/configuration.rb +699 -3
  42. data/lib/support_desk/console.rb +634 -30
  43. data/lib/support_desk/console_engine.rb +2 -0
  44. data/lib/support_desk/console_routes.rb +20 -8
  45. data/lib/support_desk/context_card.rb +33 -1
  46. data/lib/support_desk/doctor.rb +195 -2
  47. data/lib/support_desk/engine.rb +10 -0
  48. data/lib/support_desk/errors.rb +42 -0
  49. data/lib/support_desk/events.rb +12 -5
  50. data/lib/support_desk/macros.rb +41 -12
  51. data/lib/support_desk/models/assignment.rb +3 -1
  52. data/lib/support_desk/models/assistant.rb +153 -0
  53. data/lib/support_desk/models/concerns/agent.rb +39 -4
  54. data/lib/support_desk/models/concerns/requester.rb +45 -12
  55. data/lib/support_desk/models/desk.rb +26 -3
  56. data/lib/support_desk/models/draft.rb +266 -0
  57. data/lib/support_desk/models/event.rb +15 -1
  58. data/lib/support_desk/models/ticket/assistance.rb +675 -0
  59. data/lib/support_desk/models/ticket.rb +781 -120
  60. data/lib/support_desk/outcome.rb +38 -0
  61. data/lib/support_desk/queue.rb +41 -5
  62. data/lib/support_desk/summary.rb +1 -1
  63. data/lib/support_desk/test_helpers.rb +160 -3
  64. data/lib/support_desk/timeline.rb +40 -11
  65. data/lib/support_desk/topic.rb +22 -0
  66. data/lib/support_desk/topic_tree.rb +7 -1
  67. data/lib/support_desk/transcript.rb +237 -0
  68. data/lib/support_desk/version.rb +1 -1
  69. data/lib/support_desk/wizard.rb +4 -12
  70. data/lib/support_desk.rb +153 -0
  71. data/lib/tasks/support_desk.rake +73 -0
  72. metadata +35 -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.
@@ -50,10 +51,21 @@ module SupportDesk
50
51
  # fails at boot instead of silently leaving tickets unassigned.
51
52
  UNRELEASED_ROUTING_STRATEGIES = %i[round_robin least_loaded].freeze
52
53
 
54
+ # The sample a static opening line is interpolated against the moment it
55
+ # is assigned, so a typo'd %{labe} is a boot failure and not a 3am
56
+ # exception in the middle of somebody opening a case.
57
+ LINE_INTERPOLATIONS = { label: "…", desk: "…", reply_within: "…" }.freeze
58
+
53
59
  DEFAULTS = {
54
60
  name: nil,
55
61
  avatar: nil,
56
62
  email: nil,
63
+ opening_line: nil,
64
+ # A message from a desk you never wrote to has to explain itself, so
65
+ # this one has a default and `opening_line` does not: existing hosts'
66
+ # threads keep opening exactly as they do today.
67
+ opening_line_from_support: :"support_desk.thread.opened_by_support",
68
+ find_requester: nil,
57
69
  reply_policy: :anyone,
58
70
  announce_assignments: :first_only,
59
71
  closed_tickets: :reopen_on_reply,
@@ -64,7 +76,8 @@ module SupportDesk
64
76
  inbox_entry: :always,
65
77
  routing: :manual,
66
78
  mirror_replies_by_email: :when_away,
67
- auto_close_after: nil
79
+ auto_close_after: nil,
80
+ assistant: nil
68
81
  }.freeze
69
82
 
70
83
  attr_reader :key, :fallback
@@ -116,6 +129,109 @@ module SupportDesk
116
129
  @settings[:email] = value&.to_s
117
130
  end
118
131
 
132
+ # --- What a thread opens with ---------------------------------------------
133
+
134
+ # The system line every thread opens with, posted INSIDE the opening
135
+ # transaction and before the first message, so it can never arrive
136
+ # after the message it introduces (or not at all).
137
+ #
138
+ # A String with %{label}, %{desk} and %{reply_within}; a Symbol naming
139
+ # an I18n key that takes the same interpolations; a block given the
140
+ # ticket; or nil for no line at all, which is the default.
141
+ #
142
+ # config.opening_line = "Has abierto una conversación sobre «%{label}»."
143
+ # config.opening_line { |ticket| ticket.subject ? … : … }
144
+ def opening_line(&block)
145
+ return @settings[:opening_line] = block if block
146
+
147
+ read(:opening_line)
148
+ end
149
+
150
+ # Set it, validating on assignment (see the reader above).
151
+ def opening_line=(value)
152
+ @settings[:opening_line] = ensure_line(value, "opening_line")
153
+ end
154
+
155
+ # The same line for a case the DESK opened. Defaults to the gem's own
156
+ # I18n key, because somebody who never wrote to you needs to be told
157
+ # what this is.
158
+ def opening_line_from_support(&block)
159
+ return @settings[:opening_line_from_support] = block if block
160
+
161
+ read(:opening_line_from_support)
162
+ end
163
+
164
+ # Set it, validating on assignment (see the reader above).
165
+ def opening_line_from_support=(value)
166
+ @settings[:opening_line_from_support] = ensure_line(value, "opening_line_from_support")
167
+ end
168
+
169
+ # How a console finds the person an agent types: email, phone, handle,
170
+ # whatever this host lets staff search by. Given the typed string,
171
+ # returns a requester record or nil.
172
+ #
173
+ # config.find_requester { |query| User.find_by(email: query.to_s.strip.downcase) }
174
+ #
175
+ # nil (the default) means the console accepts only a GlobalID from one
176
+ # of your own pages. Multi-tenant hosts scope BOTH ways in — this hook
177
+ # is a lookup, never an authorization.
178
+ def find_requester(&block)
179
+ return @settings[:find_requester] = block if block
180
+
181
+ read(:find_requester)
182
+ end
183
+
184
+ # Set it, validating on assignment (see the reader above).
185
+ def find_requester=(value)
186
+ @settings[:find_requester] = value.nil? ? nil : ensure_callable(value, "find_requester")
187
+ end
188
+
189
+ # What's wrong with this desk's opening lines, as sentences — what
190
+ # `doctor` reports. A String is interpolated against the sample, a
191
+ # Symbol has to exist in the current locale, and a block is left alone:
192
+ # it needs a ticket, and running a host's callback as a diagnostic is
193
+ # not a diagnostic.
194
+ def opening_line_problems # :nodoc:
195
+ %i[opening_line opening_line_from_support].filter_map do |setting|
196
+ value = public_send(setting)
197
+ next if value.nil? || value.respond_to?(:call)
198
+
199
+ if value.is_a?(Symbol)
200
+ next if I18n.exists?(value)
201
+
202
+ "#{setting} names #{value.inspect}, which has no #{I18n.locale} translation"
203
+ else
204
+ begin
205
+ interpolate_line(value, LINE_INTERPOLATIONS, setting.to_s)
206
+ nil
207
+ rescue ConfigurationError => e
208
+ e.message
209
+ end
210
+ end
211
+ end
212
+ end
213
+
214
+ # What to post for THIS ticket, resolved and interpolated in the
215
+ # current locale. nil or blank means post nothing.
216
+ def opening_line_for(ticket)
217
+ setting = ticket.opened_by_support? ? opening_line_from_support : opening_line
218
+ return nil if setting.nil?
219
+
220
+ line = case setting
221
+ when Symbol then I18n.t(setting, **line_interpolations(ticket), raise: true)
222
+ when String then interpolate_line(setting, line_interpolations(ticket), "opening_line")
223
+ else setting.call(ticket)
224
+ end
225
+ return nil if line.nil?
226
+
227
+ unless line.is_a?(String)
228
+ raise ConfigurationError,
229
+ "an opening_line block must return a String or nil, got #{line.inspect}"
230
+ end
231
+
232
+ line
233
+ end
234
+
119
235
  # --- Who answers ----------------------------------------------------------
120
236
 
121
237
  # The agent pool: notified while a ticket is unassigned, offered in the
@@ -308,6 +424,45 @@ module SupportDesk
308
424
  @settings[:auto_close_after] = ensure_duration(value, "auto_close_after")
309
425
  end
310
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
+
311
466
  # --- Internals ------------------------------------------------------------
312
467
 
313
468
  def read(name) # :nodoc:
@@ -372,14 +527,439 @@ module SupportDesk
372
527
 
373
528
  value
374
529
  end
530
+
531
+ # nil, a String, an I18n key, or something to call. A String is
532
+ # interpolated here and now against the sample, so an unknown
533
+ # placeholder fails at boot rather than inside a transaction.
534
+ def ensure_line(value, name)
535
+ return value if value.nil? || value.is_a?(Symbol) || value.respond_to?(:call)
536
+ if value.is_a?(String)
537
+ interpolate_line(value, LINE_INTERPOLATIONS, name)
538
+ return value
539
+ end
540
+
541
+ raise ConfigurationError,
542
+ "#{name} must be a String, an I18n key (Symbol), a block, or nil, got #{value.inspect}"
543
+ end
544
+
545
+ # Named interpolation, NOT String#%: "100% ready" is ordinary copy in
546
+ # any language, and `%` would read that as a format directive and
547
+ # raise. I18n.interpolate leaves a literal percent alone and only
548
+ # touches %{named} placeholders.
549
+ def interpolate_line(line, interpolations, name)
550
+ I18n.interpolate(line, interpolations)
551
+ rescue KeyError, ArgumentError => e
552
+ raise ConfigurationError,
553
+ "#{name} can't be interpolated (#{e.class}: #{e.message}). The placeholders it can use are " \
554
+ "#{LINE_INTERPOLATIONS.keys.map { |key| "%{#{key}}" }.join(", ")}."
555
+ end
556
+
557
+ def line_interpolations(ticket)
558
+ { label: ticket.label, desk: ticket.desk.name,
559
+ reply_within: (SupportDesk.humanize_duration(reply_within) if reply_within) }
560
+ end
561
+ end
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
375
954
  end
376
955
 
377
956
  # Settings that belong to a desk rather than the installation. The
378
957
  # top-level accessors forward to the `:default` desk, which is also what
379
958
  # every other desk falls back to.
380
959
  DESK_SETTINGS = %i[
381
- name avatar email reply_policy announce_assignments closed_tickets reply_within at_risk_after
382
- open_rate_limit max_open_tickets inbox_entry routing mirror_replies_by_email auto_close_after
960
+ name avatar email opening_line opening_line_from_support find_requester reply_policy
961
+ announce_assignments closed_tickets reply_within at_risk_after open_rate_limit max_open_tickets
962
+ inbox_entry routing mirror_replies_by_email auto_close_after assistant
383
963
  ].freeze
384
964
 
385
965
  delegate(*DESK_SETTINGS, *DESK_SETTINGS.map { |setting| :"#{setting}=" }, to: :default_desk)
@@ -432,6 +1012,8 @@ module SupportDesk
432
1012
  @authorize_console = nil
433
1013
 
434
1014
  @desks = { default: DeskConfiguration.new(:default) }
1015
+ @assistants = {}
1016
+ @default_assistant = nil
435
1017
  @warnings = []
436
1018
  end
437
1019
 
@@ -528,6 +1110,73 @@ module SupportDesk
528
1110
  SupportDesk.on(event, &block)
529
1111
  end
530
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
+
531
1180
  # --- Validation -------------------------------------------------------------
532
1181
 
533
1182
  # Cross-field validation, run at the end of `SupportDesk.configure`.
@@ -542,6 +1191,7 @@ module SupportDesk
542
1191
  "reply_within (#{desk.reply_within.inspect}) — a ticket can't breach before it's at risk"
543
1192
  end
544
1193
 
1194
+ validate_assistants!
545
1195
  true
546
1196
  end
547
1197
 
@@ -596,6 +1246,52 @@ module SupportDesk
596
1246
 
597
1247
  private
598
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
+
599
1295
  # `config.agents { … }` has to hand back something a desk can iterate.
600
1296
  # Resolving it costs nothing at boot — a relation is lazy — and a block
601
1297
  # that returns 42, or raises, is a configuration mistake, not a 3am