support_desk 0.2.0 → 0.3.2
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 +197 -0
- data/README.md +1131 -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 +21 -3
- 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/create_support_desk_message_registrations.rb.erb +42 -0
- data/lib/generators/support_desk/templates/initializer.rb +33 -0
- data/lib/generators/support_desk/upgrade_generator.rb +25 -7
- 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 +171 -1
- data/lib/support_desk/engine.rb +16 -1
- 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/message_signatures.rb +20 -0
- data/lib/support_desk/models/assistant.rb +188 -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 +275 -0
- data/lib/support_desk/models/event.rb +15 -1
- data/lib/support_desk/models/message_registration.rb +12 -0
- data/lib/support_desk/models/ticket/assistance.rb +899 -0
- data/lib/support_desk/models/ticket.rb +406 -58
- 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 +234 -0
- data/lib/support_desk/version.rb +1 -1
- data/lib/support_desk.rb +151 -0
- data/lib/tasks/support_desk.rake +57 -0
- metadata +33 -8
|
@@ -59,8 +59,15 @@ module SupportDesk
|
|
|
59
59
|
# readonly record refuses to be destroyed. Deleting a ticket is the one
|
|
60
60
|
# thing that takes its timeline with it, and it needs no callbacks.
|
|
61
61
|
has_many :events, class_name: "SupportDesk::Event", inverse_of: :ticket, dependent: :delete_all
|
|
62
|
+
has_many :message_registrations, class_name: "SupportDesk::MessageRegistration", dependent: :delete_all
|
|
62
63
|
has_many :messages, through: :conversation, source: :messages
|
|
63
64
|
|
|
65
|
+
# The assistant surface — the turn, the policy gates, her two verbs and
|
|
66
|
+
# the two exits — in its own file. The GATES inside the verbs below stay
|
|
67
|
+
# here, next to the verbs they guard: a reader looking at `close!` has to
|
|
68
|
+
# see what stops a machine closing a case.
|
|
69
|
+
include Assistance
|
|
70
|
+
|
|
64
71
|
# Persist only the path. Resolving behavior needs this ticket's desk;
|
|
65
72
|
# an attribute caster has no record context and cannot choose the tree.
|
|
66
73
|
attribute :topic, Topic::Type.new
|
|
@@ -224,12 +231,14 @@ module SupportDesk
|
|
|
224
231
|
# something to infer from Current.actor, and an agent passed as their
|
|
225
232
|
# own requester is asking for help, not writing to themselves.
|
|
226
233
|
by_support = !same_actor?(opener, requester)
|
|
227
|
-
ensure_opener!(opener) if by_support
|
|
234
|
+
ensure_opener!(opener, desk) if by_support
|
|
228
235
|
|
|
229
236
|
# The subject is checked BEFORE the topic: "this isn't supportable" is
|
|
230
237
|
# the useful error, and an unsupportable record has no topic to find.
|
|
231
238
|
validate_subject!(about, requester)
|
|
232
239
|
node = resolve_topic!(topic, about, desk, requester, about, by_support: by_support)
|
|
240
|
+
# The topic is what caps her, so the cap is checked once it is known.
|
|
241
|
+
ensure_assistant_may_open!(opener, requester, desk, node) if by_support && SupportDesk.ai_actor?(opener)
|
|
233
242
|
|
|
234
243
|
cardinality = cardinality_key_for(requester: requester, subject: about, topic: node)
|
|
235
244
|
existing = existing_for(requester: requester, desk: desk, subject: about, topic: node)
|
|
@@ -333,10 +342,14 @@ module SupportDesk
|
|
|
333
342
|
|
|
334
343
|
# A loaded instance may predate revocation or deletion. Check the row,
|
|
335
344
|
# just as requester eligibility does; this does not serialize revocation.
|
|
345
|
+
#
|
|
346
|
+
# Returns the FRESH record, so a caller that goes on to read a runtime
|
|
347
|
+
# flag off it (the assistant's `active?`, which the policy reads) is
|
|
348
|
+
# reading the row and not the copy it was handed (R9).
|
|
336
349
|
def ensure_agent_record!(record) # :nodoc:
|
|
337
350
|
if record.respond_to?(:persisted?) && record.persisted? && record.respond_to?(:support_agent?)
|
|
338
351
|
current = record.class.uncached { record.class.find_by(id: record.id) }
|
|
339
|
-
return if current&.support_agent?
|
|
352
|
+
return current if current&.support_agent?
|
|
340
353
|
end
|
|
341
354
|
|
|
342
355
|
raise NotAnAgent, "#{record.class} is not a currently eligible, persisted support agent — " \
|
|
@@ -362,7 +375,7 @@ module SupportDesk
|
|
|
362
375
|
# Automation (`by: :system`) is refused by name rather than by
|
|
363
376
|
# NoMethodError three frames in — it is deferred work, not a typo (see
|
|
364
377
|
# docs/12-open-questions.md Q17).
|
|
365
|
-
def ensure_opener!(opener)
|
|
378
|
+
def ensure_opener!(opener, desk)
|
|
366
379
|
if opener.is_a?(Symbol)
|
|
367
380
|
raise NotAnAgent, "can't open a ticket as #{opener.inspect} — a case is opened by somebody who can " \
|
|
368
381
|
"answer it, and automation openers aren't supported yet; pass an agent record"
|
|
@@ -371,6 +384,37 @@ module SupportDesk
|
|
|
371
384
|
raise NotAnAgent, "can't open a ticket as an unsaved #{opener.class} — save the agent first"
|
|
372
385
|
end
|
|
373
386
|
ensure_agent_record!(opener)
|
|
387
|
+
return unless SupportDesk.ai_actor?(opener)
|
|
388
|
+
|
|
389
|
+
# Writing to somebody who never wrote to you is the one thing an
|
|
390
|
+
# assistant does that nobody asked for, so it takes its own
|
|
391
|
+
# permission on top of everything else.
|
|
392
|
+
unless opener.is_a?(SupportDesk::Assistant) && same_actor?(opener, desk.assistant)
|
|
393
|
+
raise NotAnAssistant,
|
|
394
|
+
"only desk #{desk.key}'s own assistant can open a case, and only when she may"
|
|
395
|
+
end
|
|
396
|
+
unless opener.may_open_conversations?
|
|
397
|
+
raise AssistantNotAllowed.new(nil, verb: :open,
|
|
398
|
+
message: "#{opener.key} may not open conversations — set " \
|
|
399
|
+
"`may_open_conversations = true` if that is the intent")
|
|
400
|
+
end
|
|
401
|
+
return if AssistantPolicy::RANK.fetch(opener.autonomy) >= AssistantPolicy::RANK.fetch(:reply)
|
|
402
|
+
|
|
403
|
+
raise AssistantNotAllowed.new(nil, verb: :open,
|
|
404
|
+
message: "#{opener.key} works at #{opener.autonomy}: opening a " \
|
|
405
|
+
"case means speaking first, which takes :reply")
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
# The topic's cap and the host's `cap` block, asked of the case this is
|
|
409
|
+
# ABOUT to be. Her per-case budget is not checked here: a brand new
|
|
410
|
+
# case has spent none of it, so the only way it could refuse is
|
|
411
|
+
# `max_turns 0`, and the policy below covers her level anyway.
|
|
412
|
+
def ensure_assistant_may_open!(opener, requester, desk, node)
|
|
413
|
+
candidate = new(desk: desk, requester: requester, topic: node)
|
|
414
|
+
policy = AssistantPolicy.for(candidate, opener)
|
|
415
|
+
return if policy.may_reply?
|
|
416
|
+
|
|
417
|
+
raise AssistantNotAllowed.new(policy, verb: :open)
|
|
374
418
|
end
|
|
375
419
|
|
|
376
420
|
def describe_record(record)
|
|
@@ -560,9 +604,12 @@ module SupportDesk
|
|
|
560
604
|
# subscriber then finds the message already registered and does
|
|
561
605
|
# nothing.
|
|
562
606
|
def post_opening!(ticket, message, files:, by:, by_support:)
|
|
563
|
-
|
|
607
|
+
notices = [ post_opening_line!(ticket) ]
|
|
564
608
|
|
|
565
|
-
posted = if by_support
|
|
609
|
+
posted = if by_support && SupportDesk.ai_actor?(by)
|
|
610
|
+
notices << ticket.send(:post_disclosure_notice!, by) if by.notice?
|
|
611
|
+
ticket.send(:post_assistant_opening!, message, files: files, assistant: by)
|
|
612
|
+
elsif by_support
|
|
566
613
|
# Never the inbound "no message, hand the ticket back" shortcut: a
|
|
567
614
|
# desk that writes first with nothing to say is a chats validation
|
|
568
615
|
# error, and this whole transaction goes with it.
|
|
@@ -572,7 +619,8 @@ module SupportDesk
|
|
|
572
619
|
end
|
|
573
620
|
|
|
574
621
|
ticket.send(:record_registration!, posted) if posted
|
|
575
|
-
|
|
622
|
+
ticket.send(:stamp_assistant_action!) if by_support && SupportDesk.ai_actor?(by)
|
|
623
|
+
pin_opening_lines!(ticket, notices, posted)
|
|
576
624
|
ticket
|
|
577
625
|
end
|
|
578
626
|
|
|
@@ -595,11 +643,16 @@ module SupportDesk
|
|
|
595
643
|
# Only this new notice moves, never anybody's real message, and the
|
|
596
644
|
# human message still owns the conversation's last-message pointer, so
|
|
597
645
|
# nothing has to be recomputed afterwards.
|
|
598
|
-
def
|
|
599
|
-
|
|
646
|
+
def pin_opening_lines!(ticket, notices, posted)
|
|
647
|
+
notices = Array(notices).compact
|
|
648
|
+
return if notices.empty?
|
|
600
649
|
|
|
601
650
|
anchor = posted&.created_at || ticket.opened_at
|
|
602
|
-
|
|
651
|
+
# Backwards, one tick each, so the last line written sits closest to
|
|
652
|
+
# the message and the first one opens the thread.
|
|
653
|
+
notices.reverse.each_with_index do |notice, index|
|
|
654
|
+
notice.update_columns(created_at: anchor - (ordering_tick * (index + 1)))
|
|
655
|
+
end
|
|
603
656
|
# When there IS a first message it owns the conversation's
|
|
604
657
|
# last-message pointer and nothing needs repairing. When there isn't,
|
|
605
658
|
# the notice is that pointer, and chats denormalised its timestamp
|
|
@@ -622,8 +675,10 @@ module SupportDesk
|
|
|
622
675
|
# running before any of it.
|
|
623
676
|
def reply_into!(ticket, message, files:, by:, by_support:, request:, authorize_reuse:)
|
|
624
677
|
if by_support
|
|
678
|
+
# Only this private outreach path supplies the current turn internally.
|
|
679
|
+
# Public replies must present the token their harness observed.
|
|
625
680
|
ticket.send(:reply_under_lock!, message, by: by, files: files, request: request,
|
|
626
|
-
authorize: authorize_reuse)
|
|
681
|
+
authorize: authorize_reuse, outreach: true)
|
|
627
682
|
else
|
|
628
683
|
ticket.with_lock(requires_new: true) do
|
|
629
684
|
authorize_reuse&.call(ticket)
|
|
@@ -803,21 +858,40 @@ module SupportDesk
|
|
|
803
858
|
# by whoever answers first and a drop-in on somebody else's ticket is
|
|
804
859
|
# recorded; under :take_over the drop-in takes it; under :assignee_only
|
|
805
860
|
# it raises SupportDesk::NotAllowed. Returns the Chats::Message.
|
|
806
|
-
|
|
861
|
+
#
|
|
862
|
+
# `by:` the assistant needs `turn:` and her policy's permission — see
|
|
863
|
+
# SupportDesk::Ticket::Assistance. `metadata:` rides on the message
|
|
864
|
+
# (provenance for a draft a human sent; hosts nest their own under
|
|
865
|
+
# "host").
|
|
866
|
+
def reply!(body = nil, by: nil, files: [], request: nil, metadata: {}, turn: nil)
|
|
807
867
|
actor = resolve_actor(by)
|
|
808
868
|
ensure_agent!(actor)
|
|
869
|
+
reconcile_and_commit! if SupportDesk.ai_actor?(actor)
|
|
809
870
|
|
|
810
|
-
reply_under_lock!(body, by: actor, files: files, request: request)
|
|
871
|
+
reply_under_lock!(body, by: actor, files: files, request: request, metadata: metadata, turn: turn)
|
|
811
872
|
end
|
|
812
873
|
|
|
813
874
|
# An internal note: in the timeline and the console, never in the
|
|
814
875
|
# conversation, never mirrored to any channel. Returns the Event.
|
|
815
|
-
def note!(body, by: nil, request: nil)
|
|
876
|
+
def note!(body, by: nil, request: nil, turn: nil)
|
|
816
877
|
actor = resolve_actor(by)
|
|
817
878
|
ensure_agent!(actor)
|
|
879
|
+
machine = SupportDesk.ai_actor?(actor)
|
|
818
880
|
raise ArgumentError, "a note needs something to say" if body.blank?
|
|
819
881
|
|
|
820
|
-
|
|
882
|
+
assistant = nil
|
|
883
|
+
event = write_transition!(:note, actor: actor, request: request) do
|
|
884
|
+
if machine
|
|
885
|
+
# Under the lock, from the row: a kill switch that commits while
|
|
886
|
+
# this call waits for the case is a kill switch that stops it (R9).
|
|
887
|
+
assistant = resolve_assistant!(actor)
|
|
888
|
+
ensure_current_turn!(turn)
|
|
889
|
+
policy = assistant_policy(assistant)
|
|
890
|
+
raise AssistantNotAllowed.new(policy, verb: :note) unless policy.may_observe?
|
|
891
|
+
end
|
|
892
|
+
{ "note" => body.to_s }
|
|
893
|
+
end
|
|
894
|
+
stamp_assistant_action! if assistant && event
|
|
821
895
|
SupportDesk.emit_after_commit(:note_added, self, event) if event
|
|
822
896
|
event
|
|
823
897
|
end
|
|
@@ -825,26 +899,44 @@ module SupportDesk
|
|
|
825
899
|
# Give the ticket to an agent. `assign!(to: lucia, by: lucia)` is
|
|
826
900
|
# somebody taking it; `assign!(to: pedro, by: admin)` is somebody being
|
|
827
901
|
# handed it. Repeating an assignment to the current holder does nothing.
|
|
828
|
-
def assign!(to:, by: nil, reason: nil, note: nil, request: nil)
|
|
902
|
+
def assign!(to:, by: nil, reason: nil, note: nil, request: nil, turn: nil)
|
|
829
903
|
actor = resolve_actor(by)
|
|
830
904
|
ensure_agent!(actor)
|
|
831
905
|
ensure_assignable!(to)
|
|
832
906
|
|
|
833
907
|
reason ||= to == actor ? :taken : :assigned
|
|
908
|
+
# A PERSON giving the case back to the assistant is the one action that
|
|
909
|
+
# lifts the three human-side flags — and it says so out loud, in the
|
|
910
|
+
# payload, because nothing else may clear them (I9).
|
|
911
|
+
handing_back = SupportDesk.ai_actor?(to) && !SupportDesk.ai_actor?(actor)
|
|
834
912
|
assignment = nil
|
|
835
913
|
|
|
836
914
|
event = write_transition!(:assigned, actor: actor, request: request) do
|
|
837
915
|
raise InvalidTransition, "can't assign a closed ticket — reopen it first" if closed?
|
|
916
|
+
# Every mutable question about a seat is asked HERE, under the row
|
|
917
|
+
# lock, from the reloaded row: who holds the case, what her policy
|
|
918
|
+
# says, which turn she is on. Asked before the lock (0.3.0) they
|
|
919
|
+
# were answers about a case that has since moved, and a stale
|
|
920
|
+
# instance could take a seat a person had already been given (R2).
|
|
921
|
+
ensure_assistant_may_assign!(actor, to, turn: turn)
|
|
922
|
+
ensure_assistant_may_hold!(to)
|
|
838
923
|
next false if assigned_to?(to)
|
|
839
924
|
|
|
840
925
|
assignment = Assignment.open!(ticket: self, agent: to, by: actor, reason: reason, note: note)
|
|
841
|
-
|
|
842
|
-
|
|
926
|
+
attributes = { assignee: to }
|
|
927
|
+
if handing_back
|
|
928
|
+
attributes.merge!(human_required_at: nil, human_required_reason: nil, assistant_paused_at: nil,
|
|
929
|
+
assistant_paused_reason: nil, assistant_cap: nil)
|
|
930
|
+
end
|
|
931
|
+
update!(attributes)
|
|
932
|
+
{ "assignee" => SupportDesk.actor_key(to), "reason" => reason.to_s,
|
|
933
|
+
"handed_back" => (true if handing_back) }.compact
|
|
843
934
|
end
|
|
844
935
|
return self unless event
|
|
845
936
|
|
|
846
937
|
announce_assignment!(to, first: assignments.count <= 1)
|
|
847
938
|
SupportDesk.emit_after_commit(:ticket_assigned, self, assignment)
|
|
939
|
+
emit_assistant_turn if handing_back && awaiting_reply?
|
|
848
940
|
self
|
|
849
941
|
end
|
|
850
942
|
|
|
@@ -853,7 +945,13 @@ module SupportDesk
|
|
|
853
945
|
def hand_off!(to:, note: nil, by: nil, request: nil)
|
|
854
946
|
actor = resolve_actor(by)
|
|
855
947
|
ensure_agent!(actor)
|
|
948
|
+
if SupportDesk.ai_actor?(actor)
|
|
949
|
+
# She does not choose who picks a case up. `escalate!` is her way
|
|
950
|
+
# out: it asks for a person rather than naming one.
|
|
951
|
+
raise AssistantNotAllowed.new(assistant_policy(resolve_assistant!(actor)), verb: :hand_off)
|
|
952
|
+
end
|
|
856
953
|
ensure_assignable!(to)
|
|
954
|
+
handing_back = SupportDesk.ai_actor?(to)
|
|
857
955
|
from = nil
|
|
858
956
|
assignment = nil
|
|
859
957
|
event = write_transition!(:handed_off, actor: actor, request: request) do
|
|
@@ -862,28 +960,48 @@ module SupportDesk
|
|
|
862
960
|
"(#{assignee ? describe_actor(assignee) : "nobody"} does) — use assign! to override"
|
|
863
961
|
end
|
|
864
962
|
raise InvalidTransition, "can't hand off a closed ticket" if closed?
|
|
963
|
+
ensure_assistant_may_hold!(to)
|
|
865
964
|
next false if assigned_to?(to)
|
|
866
965
|
|
|
867
966
|
from = assignee
|
|
868
967
|
assignment = Assignment.open!(ticket: self, agent: to, by: actor, reason: :handed_off, note: note,
|
|
869
968
|
release_reason: :handed_off)
|
|
870
|
-
|
|
871
|
-
|
|
969
|
+
attributes = { assignee: to }
|
|
970
|
+
if handing_back
|
|
971
|
+
attributes.merge!(human_required_at: nil, human_required_reason: nil, assistant_paused_at: nil,
|
|
972
|
+
assistant_paused_reason: nil, assistant_cap: nil)
|
|
973
|
+
end
|
|
974
|
+
update!(attributes)
|
|
975
|
+
{ "from" => SupportDesk.actor_key(from), "to" => SupportDesk.actor_key(to), "note" => note,
|
|
976
|
+
"handed_back" => (true if handing_back) }
|
|
872
977
|
end
|
|
873
978
|
return self unless event
|
|
874
979
|
|
|
875
980
|
announce_assignment!(to, first: false)
|
|
876
981
|
SupportDesk.emit_after_commit(:ticket_handed_off, self, assignment, from: from, note: note)
|
|
982
|
+
emit_assistant_turn if handing_back && awaiting_reply?
|
|
877
983
|
self
|
|
878
984
|
end
|
|
879
985
|
|
|
880
986
|
# Put the ticket back in the unassigned pile.
|
|
881
|
-
def release!(by: nil, reason: :released, request: nil)
|
|
987
|
+
def release!(by: nil, reason: :released, request: nil, turn: nil)
|
|
882
988
|
actor = resolve_actor(by)
|
|
883
989
|
ensure_agent!(actor)
|
|
884
990
|
from = nil
|
|
885
991
|
event = write_transition!(:released, actor: actor, request: request) do
|
|
886
992
|
raise InvalidTransition, "can't release a closed ticket" if closed?
|
|
993
|
+
assistant = (resolve_assistant!(actor) if SupportDesk.ai_actor?(actor))
|
|
994
|
+
if assistant
|
|
995
|
+
# Giving a seat back is an action like any other, so it holds the
|
|
996
|
+
# turn it read: a run that finished after the case moved on must
|
|
997
|
+
# not release the seat a newer one took (R2).
|
|
998
|
+
ensure_current_turn!(turn)
|
|
999
|
+
policy = assistant_policy(assistant)
|
|
1000
|
+
raise AssistantNotAllowed.new(policy, verb: :release) unless policy.may_observe?
|
|
1001
|
+
# Her own seat, and only hers: putting a PERSON's case back in the
|
|
1002
|
+
# pile is not something a machine gets to decide.
|
|
1003
|
+
raise AssistantNotAllowed.new(policy, verb: :release) unless assigned_to?(assistant)
|
|
1004
|
+
end
|
|
887
1005
|
next false if unassigned?
|
|
888
1006
|
|
|
889
1007
|
from = assignee
|
|
@@ -898,17 +1016,37 @@ module SupportDesk
|
|
|
898
1016
|
end
|
|
899
1017
|
|
|
900
1018
|
# Close the case. Closing a closed ticket is a no-op, not an error.
|
|
901
|
-
def close!(by: nil, request: nil)
|
|
1019
|
+
def close!(by: nil, request: nil, turn: nil)
|
|
902
1020
|
actor = resolve_actor(by)
|
|
903
1021
|
ensure_agent!(actor)
|
|
1022
|
+
machine = SupportDesk.ai_actor?(actor)
|
|
1023
|
+
reconcile_and_commit! if machine
|
|
904
1024
|
|
|
905
1025
|
event = write_transition!(:closed, actor: actor, request: request) do
|
|
1026
|
+
if machine
|
|
1027
|
+
# Resolved under the lock, so the kill switch is read after the
|
|
1028
|
+
# wait rather than before it (R9).
|
|
1029
|
+
assistant = resolve_assistant!(actor)
|
|
1030
|
+
lock_conversation!
|
|
1031
|
+
reconcile_unregistered_messages!
|
|
1032
|
+
ensure_current_turn!(turn)
|
|
1033
|
+
policy = assistant_policy(assistant)
|
|
1034
|
+
# Four conditions, and every one of them is somebody else's word:
|
|
1035
|
+
# her level allows it, she is the one holding the case, the
|
|
1036
|
+
# customer has the last word (so nothing is waiting for an answer)
|
|
1037
|
+
# and nobody has asked for a person.
|
|
1038
|
+
raise AssistantNotAllowed.new(policy, verb: :close) unless policy.may_close?
|
|
1039
|
+
raise AssistantNotAllowed.new(policy, verb: :close) unless assigned_to?(assistant)
|
|
1040
|
+
raise AssistantNotAllowed.new(policy, verb: :close) unless awaiting_requester?
|
|
1041
|
+
raise AssistantNotAllowed.new(policy, verb: :close) if human_required?
|
|
1042
|
+
end
|
|
906
1043
|
next false if closed?
|
|
907
1044
|
|
|
1045
|
+
expired = expire_pending_drafts!
|
|
908
1046
|
assignments.open.each { |assignment| assignment.release!(reason: :closed) }
|
|
909
1047
|
update!(status: "closed", closed_at: Time.current, closed_by: record_actor(actor),
|
|
910
1048
|
awaiting: "none", waiting_since: nil)
|
|
911
|
-
{}
|
|
1049
|
+
{ "expired_drafts" => (expired if expired.positive?) }
|
|
912
1050
|
end
|
|
913
1051
|
return self unless event
|
|
914
1052
|
|
|
@@ -924,10 +1062,17 @@ module SupportDesk
|
|
|
924
1062
|
# the pool.
|
|
925
1063
|
def reopen!(by: nil, request: nil)
|
|
926
1064
|
actor = resolve_actor(by)
|
|
1065
|
+
if SupportDesk.ai_actor?(actor)
|
|
1066
|
+
raise AssistantNotAllowed.new(assistant_policy(resolve_assistant!(actor)), verb: :reopen)
|
|
1067
|
+
end
|
|
927
1068
|
|
|
928
1069
|
event = write_transition!(:reopened, actor: actor, request: request) do
|
|
929
1070
|
next false unless closed?
|
|
930
1071
|
|
|
1072
|
+
# Read BEFORE the update clears it: whether the case we are bringing
|
|
1073
|
+
# back is one the assistant closed is the whole question behind the
|
|
1074
|
+
# cap below, and `closed_by` is where it is written down.
|
|
1075
|
+
closed_by_assistant = closed_by_type == SupportDesk::Assistant.polymorphic_name
|
|
931
1076
|
# Order matters: waiting_since is DERIVED from awaiting, so awaiting
|
|
932
1077
|
# has to be the reopened value before it is read. Computing both in
|
|
933
1078
|
# one update! hash reads the closed ticket's "none" and stores nil —
|
|
@@ -937,8 +1082,8 @@ module SupportDesk
|
|
|
937
1082
|
cardinality_key: "reopened:#{id}")
|
|
938
1083
|
self.waiting_since = waiting_since_from_clocks
|
|
939
1084
|
save!
|
|
940
|
-
restore_assignment!(by: actor)
|
|
941
|
-
{ "reopen_count" => reopen_count }
|
|
1085
|
+
capped = restore_assignment!(by: actor, closed_by_assistant: closed_by_assistant)
|
|
1086
|
+
{ "reopen_count" => reopen_count, "assistant_capped" => (true if capped) }.compact
|
|
942
1087
|
end
|
|
943
1088
|
return self unless event
|
|
944
1089
|
|
|
@@ -953,6 +1098,12 @@ module SupportDesk
|
|
|
953
1098
|
# Agents may file onto any topic in the tree, including ones no
|
|
954
1099
|
# requester is offered (`only:`); requesters may not file at all.
|
|
955
1100
|
ensure_agent!(actor)
|
|
1101
|
+
# Triage is where authority comes from — a topic decides the cap she
|
|
1102
|
+
# works under, so refiling her own case would be widening her own
|
|
1103
|
+
# policy (I10). 0.3 refuses it outright.
|
|
1104
|
+
if SupportDesk.ai_actor?(actor)
|
|
1105
|
+
raise AssistantNotAllowed.new(assistant_policy(resolve_assistant!(actor)), verb: :triage)
|
|
1106
|
+
end
|
|
956
1107
|
node = desk_config.topics.find(to.to_s) ||
|
|
957
1108
|
raise(UnknownTopic, "no topic #{to.inspect} on desk #{desk.key}")
|
|
958
1109
|
|
|
@@ -962,7 +1113,11 @@ module SupportDesk
|
|
|
962
1113
|
|
|
963
1114
|
update!(topic: node, priority: [ priority.to_i, node.priority ].max,
|
|
964
1115
|
cardinality_key: recomputed_cardinality_key(subject: subject, topic: node))
|
|
965
|
-
|
|
1116
|
+
# A refile onto a capped topic is a refile onto a case she may no
|
|
1117
|
+
# longer answer, so her seat goes with it — in the same transition,
|
|
1118
|
+
# so the queue never shows a machine holding a case it can't work.
|
|
1119
|
+
released = release_assistant_if_unfit!
|
|
1120
|
+
{ "from" => from&.path, "to" => node.path, "assistant_released" => (true if released) }
|
|
966
1121
|
end
|
|
967
1122
|
return self unless event
|
|
968
1123
|
|
|
@@ -974,6 +1129,11 @@ module SupportDesk
|
|
|
974
1129
|
def attach_subject!(record, by: nil, request: nil)
|
|
975
1130
|
actor = resolve_actor(by)
|
|
976
1131
|
ensure_agent!(actor)
|
|
1132
|
+
# Same reason as `change_topic!`: what a case is ABOUT decides what may
|
|
1133
|
+
# be done on it, so a machine does not get to say.
|
|
1134
|
+
if SupportDesk.ai_actor?(actor)
|
|
1135
|
+
raise AssistantNotAllowed.new(assistant_policy(resolve_assistant!(actor)), verb: :triage)
|
|
1136
|
+
end
|
|
977
1137
|
unless record.respond_to?(:supportable?) && record.supportable?
|
|
978
1138
|
raise NotSupportable, "#{record.class} isn't supportable — add `supportable topic: :something` to it"
|
|
979
1139
|
end
|
|
@@ -1052,6 +1212,9 @@ module SupportDesk
|
|
|
1052
1212
|
# test instead of quietly moving the boundary.
|
|
1053
1213
|
def actions_for(agent)
|
|
1054
1214
|
return [] unless agent.respond_to?(:support_agent?) && agent.support_agent?
|
|
1215
|
+
# A machine's verbs are its policy's, filtered by the same state. Same
|
|
1216
|
+
# boundary, different vocabulary — see #assistant_actions_for.
|
|
1217
|
+
return assistant_actions_for(agent) if SupportDesk.ai_actor?(agent)
|
|
1055
1218
|
# Off duty is a real answer: the console can still show the case, and
|
|
1056
1219
|
# an agent passing by can still leave a note, but nothing that speaks
|
|
1057
1220
|
# to the requester is offered to somebody who isn't working.
|
|
@@ -1070,6 +1233,11 @@ module SupportDesk
|
|
|
1070
1233
|
actions << :release if assigned?
|
|
1071
1234
|
actions << :change_topic
|
|
1072
1235
|
actions << :close
|
|
1236
|
+
if pending_draft
|
|
1237
|
+
actions << :send_draft if may_reply?(agent) && !requester_unavailable?
|
|
1238
|
+
actions << :reject_draft
|
|
1239
|
+
end
|
|
1240
|
+
actions << (assistant_paused? ? :resume_assistant : :pause_assistant) if assistant
|
|
1073
1241
|
end
|
|
1074
1242
|
actions
|
|
1075
1243
|
end
|
|
@@ -1077,6 +1245,10 @@ module SupportDesk
|
|
|
1077
1245
|
# Whether +agent+ may answer right now under this desk's reply policy.
|
|
1078
1246
|
def may_reply?(agent)
|
|
1079
1247
|
return false unless agent.respond_to?(:support_agent?) && agent.support_agent?
|
|
1248
|
+
# A case a machine is holding is a case any person may answer,
|
|
1249
|
+
# whatever the desk says about assignees: `:assignee_only` exists so
|
|
1250
|
+
# two people don't answer at once, and she is not one (I8).
|
|
1251
|
+
return true if held_by_assistant? && !SupportDesk.ai_actor?(agent)
|
|
1080
1252
|
return true unless desk_config.reply_policy == :assignee_only
|
|
1081
1253
|
|
|
1082
1254
|
assigned_to?(agent)
|
|
@@ -1086,9 +1258,11 @@ module SupportDesk
|
|
|
1086
1258
|
# the whole on-duty pool while it's unheld. The gem computes it; the
|
|
1087
1259
|
# host delivers it.
|
|
1088
1260
|
def agents_to_notify
|
|
1089
|
-
|
|
1261
|
+
# A case the assistant holds is a case no person has seen, so the pool
|
|
1262
|
+
# hears about it: notifying a machine is notifying nobody.
|
|
1263
|
+
return [ assignee ].compact if assigned? && !held_by_assistant?
|
|
1090
1264
|
|
|
1091
|
-
desk.on_duty_agents.to_a
|
|
1265
|
+
desk.on_duty_agents.to_a.reject { |agent| SupportDesk.ai_actor?(agent) }
|
|
1092
1266
|
end
|
|
1093
1267
|
|
|
1094
1268
|
# Generic on purpose: a lock screen shouldn't spell out what somebody's
|
|
@@ -1132,8 +1306,15 @@ module SupportDesk
|
|
|
1132
1306
|
# Post a message as the DESK, signed by the agent who wrote it — every
|
|
1133
1307
|
# answer, and the desk's first word when it writes first. One place knows
|
|
1134
1308
|
# "the desk sends, the human signs".
|
|
1135
|
-
|
|
1136
|
-
|
|
1309
|
+
# `metadata:` is the provenance a message carries: who drafted it, what
|
|
1310
|
+
# policy allowed it, what the machine declared about it. Posted through
|
|
1311
|
+
# `messages.create!` rather than `desk.message!` for one reason only —
|
|
1312
|
+
# chats' sugar takes no metadata, and the validations are the same.
|
|
1313
|
+
def post_agent_message!(body, files: [], by:, metadata: {}) # :nodoc:
|
|
1314
|
+
attributes = { sender: desk, body: body, author: by }
|
|
1315
|
+
attributes[:files] = files if files.present?
|
|
1316
|
+
attributes[:metadata] = metadata if metadata.present?
|
|
1317
|
+
conversation.messages.create!(**attributes)
|
|
1137
1318
|
end
|
|
1138
1319
|
|
|
1139
1320
|
def inspect
|
|
@@ -1172,8 +1353,50 @@ module SupportDesk
|
|
|
1172
1353
|
ensure_agent!(agent)
|
|
1173
1354
|
end
|
|
1174
1355
|
|
|
1356
|
+
# "Could she hold this case once the human-side flags were lifted?" —
|
|
1357
|
+
# the hand-back question, so the very flags a hand-back exists to clear
|
|
1358
|
+
# are not what refuses it. Her autonomy, the topic and the host's cap
|
|
1359
|
+
# block still decide.
|
|
1360
|
+
#
|
|
1361
|
+
# Asked UNDER THE LOCK (R2): a cap that lands while a take waits for the
|
|
1362
|
+
# row is the cap that applies to it.
|
|
1363
|
+
def ensure_assistant_may_hold!(agent)
|
|
1364
|
+
return unless SupportDesk.ai_actor?(agent)
|
|
1365
|
+
|
|
1366
|
+
policy = assistant_policy(agent, hand_back: true)
|
|
1367
|
+
raise AssistantNotAllowed.new(policy, verb: :take) unless policy.may_hold?
|
|
1368
|
+
end
|
|
1369
|
+
|
|
1370
|
+
# An assistant may take a case, and that is all: only herself, only when
|
|
1371
|
+
# nobody holds it, only at a level that may answer, and only holding the
|
|
1372
|
+
# turn she read. Called under the row lock, from the reloaded row.
|
|
1373
|
+
def ensure_assistant_may_assign!(actor, to, turn:)
|
|
1374
|
+
return unless SupportDesk.ai_actor?(actor)
|
|
1375
|
+
|
|
1376
|
+
assistant = resolve_assistant!(actor)
|
|
1377
|
+
ensure_current_turn!(turn)
|
|
1378
|
+
policy = assistant_policy(assistant)
|
|
1379
|
+
unless self.class.same_actor?(actor, to)
|
|
1380
|
+
raise AssistantNotAllowed.new(policy, verb: :assign,
|
|
1381
|
+
message: "#{assistant.key} may not give #{reference} to " \
|
|
1382
|
+
"anybody — an assistant can only take a case herself")
|
|
1383
|
+
end
|
|
1384
|
+
raise AssistantNotAllowed.new(policy, verb: :take) unless unassigned?
|
|
1385
|
+
raise AssistantNotAllowed.new(policy, verb: :take) unless policy.may_hold?
|
|
1386
|
+
end
|
|
1387
|
+
|
|
1175
1388
|
def ensure_agent!(actor)
|
|
1176
1389
|
return if actor.is_a?(Symbol)
|
|
1390
|
+
# An AI-kind actor that is not a SupportDesk::Assistant is refused on
|
|
1391
|
+
# every write, by it or to it (I2). A host model declared `kind: :ai`
|
|
1392
|
+
# is a machine the gem knows nothing about, and treating it as a human
|
|
1393
|
+
# agent — which is what 0.2 did — hands it every human's authority.
|
|
1394
|
+
if SupportDesk.ai_actor?(actor) && !actor.is_a?(SupportDesk::Assistant)
|
|
1395
|
+
raise NotAnAssistant,
|
|
1396
|
+
"#{describe_actor(actor)} is declared `acts_as_support_agent kind: :ai` but isn't this gem's " \
|
|
1397
|
+
"assistant — configure one with `config.assistant` and act as SupportDesk.assistant(key)"
|
|
1398
|
+
end
|
|
1399
|
+
|
|
1177
1400
|
self.class.ensure_agent_record!(actor)
|
|
1178
1401
|
end
|
|
1179
1402
|
|
|
@@ -1211,17 +1434,52 @@ module SupportDesk
|
|
|
1211
1434
|
# `authorize:` is the console's hook (see Ticket.open_or_reply!): it runs
|
|
1212
1435
|
# under this lock, before any policy side effect, and a raise there rolls
|
|
1213
1436
|
# the whole thing back.
|
|
1214
|
-
def reply_under_lock!(body, by:, files:, request:, authorize: nil)
|
|
1437
|
+
def reply_under_lock!(body, by:, files:, request:, authorize: nil, metadata: {}, turn: nil, outreach: false)
|
|
1215
1438
|
with_lock(requires_new: true) do
|
|
1216
1439
|
authorize&.call(self)
|
|
1440
|
+
next assistant_reply_under_lock!(body, assistant: by, files: files, request: request,
|
|
1441
|
+
metadata: metadata, turn: turn, outreach: outreach) if SupportDesk.ai_actor?(by)
|
|
1442
|
+
|
|
1443
|
+
# Fold committed inputs before a human answer too. A delayed callback
|
|
1444
|
+
# must not later turn an already-answered question back into work.
|
|
1445
|
+
lock_conversation!
|
|
1446
|
+
reconcile_unregistered_messages!
|
|
1217
1447
|
ensure_writable!
|
|
1218
1448
|
apply_reply_policy!(by, request: request)
|
|
1219
|
-
posted = post_agent_message!(body, files: files, by: by)
|
|
1449
|
+
posted = post_agent_message!(body, files: files, by: by, metadata: metadata)
|
|
1450
|
+
# A person answering makes every machine proposal on this case out of
|
|
1451
|
+
# date — including one they are about to send, which is why the draft
|
|
1452
|
+
# being sent says so and is left alone.
|
|
1453
|
+
supersede_pending_drafts!(except: metadata.dig("support_desk", "draft_id"))
|
|
1220
1454
|
record_registration!(posted)
|
|
1221
1455
|
posted
|
|
1222
1456
|
end
|
|
1223
1457
|
end
|
|
1224
1458
|
|
|
1459
|
+
# `reply!` by the assistant: her full rules, under the lock the caller
|
|
1460
|
+
# already holds. Everything here is also what `respond!` runs — this is
|
|
1461
|
+
# the path for a host that decided to answer rather than ask policy.
|
|
1462
|
+
def assistant_reply_under_lock!(body, assistant:, files:, request:, metadata:, turn:, outreach: false)
|
|
1463
|
+
assistant = resolve_assistant!(assistant)
|
|
1464
|
+
# Private outreach supplies a token only after acquiring both locks;
|
|
1465
|
+
# public replies compare the caller's actual observed token.
|
|
1466
|
+
lock_conversation!
|
|
1467
|
+
reconcile_unregistered_messages!
|
|
1468
|
+
turn = assistant_turn if outreach
|
|
1469
|
+
ensure_current_turn!(turn)
|
|
1470
|
+
ensure_writable!
|
|
1471
|
+
|
|
1472
|
+
policy = assistant_policy(assistant)
|
|
1473
|
+
raise AssistantNotAllowed.new(policy, verb: :reply) unless policy.may_reply?
|
|
1474
|
+
raise AssistantNotAllowed.new(policy, verb: :not_your_turn) unless awaiting_reply?
|
|
1475
|
+
|
|
1476
|
+
left = assistant_turns_left
|
|
1477
|
+
raise AssistantNotAllowed.new(policy, verb: :max_turns) if left&.zero?
|
|
1478
|
+
|
|
1479
|
+
speak!(body, assistant, policy: policy, turn: turn, files: files, confidence: nil, sources: [],
|
|
1480
|
+
metadata: metadata, request: request)
|
|
1481
|
+
end
|
|
1482
|
+
|
|
1225
1483
|
def apply_reply_policy!(actor, request: nil)
|
|
1226
1484
|
# A closed case has no seat to take: an agent adding one last word
|
|
1227
1485
|
# posts it and the case stays closed. (A REQUESTER writing is what
|
|
@@ -1236,14 +1494,27 @@ module SupportDesk
|
|
|
1236
1494
|
"take it first"
|
|
1237
1495
|
end
|
|
1238
1496
|
|
|
1239
|
-
|
|
1497
|
+
# This private path already validated the reply while holding the lock.
|
|
1498
|
+
# Seat-taking consumes the real current token, not a public sentinel.
|
|
1499
|
+
assign!(to: actor, by: actor, request: request, turn: assistant_turn)
|
|
1240
1500
|
elsif !assigned_to?(actor)
|
|
1501
|
+
# Humans outrank assistants, under EVERY reply policy (I8). A person
|
|
1502
|
+
# answering a case a machine is holding takes it over: there is
|
|
1503
|
+
# nothing to ask about "who owns this" when one of the two can't
|
|
1504
|
+
# want it, and leaving her seated would keep her answering next.
|
|
1505
|
+
if held_by_assistant? && !SupportDesk.ai_actor?(actor)
|
|
1506
|
+
return assign!(to: actor, by: actor, reason: :drop_in_takeover, request: request, turn: assistant_turn)
|
|
1507
|
+
end
|
|
1508
|
+
# Belt: the `held_by_human` floor already turned this into a draft,
|
|
1509
|
+
# so an assistant reaching here is a bug, not a policy question.
|
|
1510
|
+
raise AssistantNotAllowed.new(assistant_policy(actor), verb: :reply) if SupportDesk.ai_actor?(actor)
|
|
1511
|
+
|
|
1241
1512
|
case policy
|
|
1242
1513
|
when :assignee_only
|
|
1243
1514
|
raise NotAllowed, "ticket #{reference} is held by #{describe_actor(assignee)} and this desk only " \
|
|
1244
1515
|
"lets the assignee reply"
|
|
1245
1516
|
when :take_over
|
|
1246
|
-
assign!(to: actor, by: actor, reason: :drop_in_takeover, request: request)
|
|
1517
|
+
assign!(to: actor, by: actor, reason: :drop_in_takeover, request: request, turn: assistant_turn)
|
|
1247
1518
|
else
|
|
1248
1519
|
write_transition!(:drop_in, actor: actor, request: request) do
|
|
1249
1520
|
{ "assignee" => SupportDesk.actor_key(assignee) }
|
|
@@ -1270,7 +1541,11 @@ module SupportDesk
|
|
|
1270
1541
|
payload = yield
|
|
1271
1542
|
return nil if payload == false || payload.nil?
|
|
1272
1543
|
|
|
1273
|
-
Event.record!(ticket: self, kind: kind, actor: actor, payload: payload.compact)
|
|
1544
|
+
event = Event.record!(ticket: self, kind: kind, actor: actor, payload: payload.compact)
|
|
1545
|
+
# A real transition changed the case, so it moves the turn — a
|
|
1546
|
+
# no-op one wrote no event and moves nothing.
|
|
1547
|
+
bump_assistant_revision!
|
|
1548
|
+
event
|
|
1274
1549
|
end
|
|
1275
1550
|
|
|
1276
1551
|
# The half that tells the world, once the write is durable. Every
|
|
@@ -1288,6 +1563,10 @@ module SupportDesk
|
|
|
1288
1563
|
end
|
|
1289
1564
|
|
|
1290
1565
|
def announce_assignment!(agent, first:)
|
|
1566
|
+
# "Rose se ocupa de tu consulta" is a promise about a person. Her
|
|
1567
|
+
# disclosure line is her announcement, and it is the only one.
|
|
1568
|
+
return if SupportDesk.ai_actor?(agent)
|
|
1569
|
+
|
|
1291
1570
|
mode = desk_config.announce_assignments
|
|
1292
1571
|
return if mode == :never
|
|
1293
1572
|
return if mode == :first_only && !first
|
|
@@ -1310,27 +1589,37 @@ module SupportDesk
|
|
|
1310
1589
|
# caller is not always the lock holder it thinks it is.
|
|
1311
1590
|
def record_registration!(message) # :nodoc:
|
|
1312
1591
|
return self if role_of(message) == :system
|
|
1592
|
+
unless message.persisted? && message.conversation_id == conversation_id
|
|
1593
|
+
raise ArgumentError, "message must belong to this ticket's conversation"
|
|
1594
|
+
end
|
|
1313
1595
|
# Re-check under the lock: the same message can reach us twice (a
|
|
1314
1596
|
# redelivered event, a hand-written replay), and an SLA clock that
|
|
1315
1597
|
# moves twice for one message is a lie.
|
|
1316
1598
|
return self if registered?(message)
|
|
1317
1599
|
|
|
1600
|
+
message_registrations.create!(message_id: message.id)
|
|
1318
1601
|
role = role_of(message)
|
|
1319
1602
|
opening = opening_message?
|
|
1320
1603
|
reopened = false
|
|
1321
1604
|
reopen_event = nil
|
|
1322
1605
|
|
|
1323
1606
|
attributes = { last_registered_message_id: message.id }
|
|
1607
|
+
closed_by_assistant = false
|
|
1324
1608
|
case role
|
|
1325
1609
|
when :requester
|
|
1326
|
-
attributes[:last_requester_message_at] = message.created_at
|
|
1610
|
+
attributes[:last_requester_message_at] = [ last_requester_message_at, message.created_at ].compact.max
|
|
1611
|
+
# The pointer the turn's reconciliation reads: which requester
|
|
1612
|
+
# message the clocks are standing on, by id and not only by time.
|
|
1613
|
+
attributes[:last_requester_message_id] = message.id if after_requester_watermark?(message)
|
|
1327
1614
|
if closed? && message.created_at > closed_at && desk_config.closed_tickets == :reopen_on_reply
|
|
1615
|
+
# Read before the merge below clears it — see #reopen!.
|
|
1616
|
+
closed_by_assistant = closed_by_type == SupportDesk::Assistant.polymorphic_name
|
|
1328
1617
|
attributes.merge!(status: "open", closed_at: nil, closed_by: nil,
|
|
1329
1618
|
reopen_count: reopen_count.to_i + 1, cardinality_key: "reopened:#{id}")
|
|
1330
1619
|
reopened = true
|
|
1331
1620
|
end
|
|
1332
1621
|
when :agent
|
|
1333
|
-
attributes[:last_agent_message_at] = message.created_at
|
|
1622
|
+
attributes[:last_agent_message_at] = [ last_agent_message_at, message.created_at ].compact.max
|
|
1334
1623
|
# A first REPLY answers something. An agent message with no earlier
|
|
1335
1624
|
# requester message is the desk opening the conversation, or a word
|
|
1336
1625
|
# into an empty case — neither is a reply, and a requester clock that
|
|
@@ -1345,41 +1634,74 @@ module SupportDesk
|
|
|
1345
1634
|
end
|
|
1346
1635
|
|
|
1347
1636
|
assign_attributes(attributes)
|
|
1348
|
-
self.awaiting = closed?
|
|
1637
|
+
self.awaiting = if closed?
|
|
1638
|
+
"none"
|
|
1639
|
+
elsif role == :requester
|
|
1640
|
+
"agent"
|
|
1641
|
+
else
|
|
1642
|
+
awaiting_from_clocks
|
|
1643
|
+
end
|
|
1349
1644
|
self.waiting_since = waiting_since_from_clocks
|
|
1350
1645
|
save!
|
|
1646
|
+
# Every registered message moves the case on, so every one of them
|
|
1647
|
+
# moves the turn: an assistant holding the turn she read a second ago
|
|
1648
|
+
# is holding a case that has not changed since (I4).
|
|
1649
|
+
bump_assistant_revision!
|
|
1351
1650
|
|
|
1352
1651
|
if reopened
|
|
1353
|
-
restore_assignment!(by: :system)
|
|
1354
|
-
reopen_event = record_transition!(:reopened, actor: requester)
|
|
1652
|
+
capped = restore_assignment!(by: :system, closed_by_assistant: closed_by_assistant)
|
|
1653
|
+
reopen_event = record_transition!(:reopened, actor: requester) do
|
|
1654
|
+
{ "via" => "requester_reply", "assistant_capped" => (true if capped) }.compact
|
|
1655
|
+
end
|
|
1355
1656
|
end
|
|
1356
1657
|
|
|
1658
|
+
# After the bump, and after any reopen: the hook decides about a case
|
|
1659
|
+
# in the state this message left it in.
|
|
1660
|
+
evaluate_hand_off_phrase!(message) if role == :requester && !closed?
|
|
1661
|
+
|
|
1357
1662
|
publish_transition(reopen_event, :reopened, requester, nil) if reopen_event
|
|
1358
1663
|
announce_registration(message, role: role, opening: opening, reopened: reopened)
|
|
1359
1664
|
self
|
|
1360
1665
|
end
|
|
1361
1666
|
|
|
1362
|
-
#
|
|
1363
|
-
#
|
|
1364
|
-
# REPLAY can arrive in any order and an older message must never rewind
|
|
1365
|
-
# `awaiting`, restart an SLA clock, or reopen a case that was closed
|
|
1366
|
-
# after it.
|
|
1367
|
-
#
|
|
1368
|
-
# The comparison is `<=`, so a message whose timestamp already sits on
|
|
1369
|
-
# the clock counts as folded in: idempotency is the documented promise,
|
|
1370
|
-
# and the cost of the rare tie is one clock that doesn't advance until
|
|
1371
|
-
# the next message.
|
|
1667
|
+
# Receipt identity, not transcript order, decides whether a callback is a
|
|
1668
|
+
# replay. A later commit may have an earlier timestamp or a smaller UUID.
|
|
1372
1669
|
def registered?(message)
|
|
1373
|
-
|
|
1670
|
+
message_registrations.exists?(message_id: message.id)
|
|
1671
|
+
end
|
|
1374
1672
|
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1673
|
+
# Advance the requester clock pointer only in transcript order. Receipt
|
|
1674
|
+
# identity decides registration separately, so late arrivals cannot rewind
|
|
1675
|
+
# clocks or be discarded as replays.
|
|
1676
|
+
def after_requester_watermark?(message)
|
|
1677
|
+
watermark = last_requester_message_at
|
|
1678
|
+
return true if watermark.blank?
|
|
1679
|
+
return true if message.created_at > watermark
|
|
1680
|
+
return false if message.created_at < watermark
|
|
1681
|
+
# A tie with no pointer to break it (a 0.2 row whose backfill found
|
|
1682
|
+
# nothing) is new: the alternative silently drops it.
|
|
1683
|
+
return true if last_requester_message_id.blank?
|
|
1684
|
+
|
|
1685
|
+
compare_message_ids(message.id, last_requester_message_id).positive?
|
|
1686
|
+
end
|
|
1379
1687
|
|
|
1380
|
-
|
|
1688
|
+
# Order two chats message ids the way the DATABASE orders that column,
|
|
1689
|
+
# because the reconciliation query compares them in SQL and this one
|
|
1690
|
+
# compares them in Ruby. Integers compare numerically (10 is after 9);
|
|
1691
|
+
# anything else — a uuid, a ULID — compares as a string, which is how
|
|
1692
|
+
# every adapter orders those columns too.
|
|
1693
|
+
def compare_message_ids(one, other)
|
|
1694
|
+
if one.is_a?(Integer) && other.is_a?(Integer)
|
|
1695
|
+
one <=> other
|
|
1696
|
+
elsif integerish?(one) && integerish?(other)
|
|
1697
|
+
one.to_i <=> other.to_i
|
|
1698
|
+
else
|
|
1699
|
+
one.to_s <=> other.to_s
|
|
1700
|
+
end
|
|
1381
1701
|
end
|
|
1382
1702
|
|
|
1703
|
+
def integerish?(value) = value.to_s.match?(/\A-?\d+\z/)
|
|
1704
|
+
|
|
1383
1705
|
def opening_message?
|
|
1384
1706
|
last_registered_message_id.blank? && last_requester_message_at.nil? && last_agent_message_at.nil?
|
|
1385
1707
|
end
|
|
@@ -1432,14 +1754,26 @@ module SupportDesk
|
|
|
1432
1754
|
# disagree on a live ticket. A closed ticket keeps its assignee as the
|
|
1433
1755
|
# record of who dealt with it, with no open row — that pair is the one
|
|
1434
1756
|
# shape `doctor` expects to see.
|
|
1435
|
-
def restore_assignment!(by:)
|
|
1436
|
-
return if unassigned?
|
|
1757
|
+
def restore_assignment!(by:, closed_by_assistant: false)
|
|
1758
|
+
return false if unassigned?
|
|
1759
|
+
|
|
1760
|
+
# A case the assistant closed and the customer reopened is a case she
|
|
1761
|
+
# got wrong (12 #21). It comes back to PEOPLE — unassigned, and capped
|
|
1762
|
+
# at :draft on this case for good, whatever her level is elsewhere. An
|
|
1763
|
+
# explicit hand-back is the only thing that lifts it, and the cap can
|
|
1764
|
+
# only tighten: an existing :observe stays :observe.
|
|
1765
|
+
if closed_by_assistant
|
|
1766
|
+
cap = [ assistant_cap&.to_sym, :draft ].compact.min_by { |level| AssistantPolicy::RANK.fetch(level) }
|
|
1767
|
+
update!(assignee: nil, assistant_cap: cap.to_s)
|
|
1768
|
+
return true
|
|
1769
|
+
end
|
|
1437
1770
|
|
|
1438
1771
|
if assignee.respond_to?(:support_agent?) && assignee.support_agent?
|
|
1439
1772
|
Assignment.open!(ticket: self, agent: assignee, by: by, reason: :reopened)
|
|
1440
1773
|
else
|
|
1441
1774
|
update!(assignee: nil)
|
|
1442
1775
|
end
|
|
1776
|
+
false
|
|
1443
1777
|
end
|
|
1444
1778
|
|
|
1445
1779
|
def awaiting_from_clocks
|
|
@@ -1464,6 +1798,10 @@ module SupportDesk
|
|
|
1464
1798
|
|
|
1465
1799
|
case role
|
|
1466
1800
|
when :requester
|
|
1801
|
+
# The opening message included: a case that starts with a question
|
|
1802
|
+
# is a case with something to answer, and the harness should hear
|
|
1803
|
+
# about it the same way it hears about every later message.
|
|
1804
|
+
emit_assistant_turn(message)
|
|
1467
1805
|
SupportDesk.emit_after_commit(:requester_replied, self, message) unless opening
|
|
1468
1806
|
when :agent
|
|
1469
1807
|
# The desk's OWN first word announces nothing: it is the start of a
|
|
@@ -1497,11 +1835,21 @@ module SupportDesk
|
|
|
1497
1835
|
conversation.messages.visible.oldest_first.map do |message|
|
|
1498
1836
|
{
|
|
1499
1837
|
at: message.created_at,
|
|
1500
|
-
|
|
1838
|
+
# "assistant" in every disclosure mode, silent ones included: what
|
|
1839
|
+
# a customer was told is a product decision, what an export says a
|
|
1840
|
+
# machine wrote is not.
|
|
1841
|
+
from: export_from(message),
|
|
1501
1842
|
body: message.visible_body,
|
|
1502
1843
|
attachments: message.try(:files)&.map { |file| file.try(:filename).to_s } || []
|
|
1503
1844
|
}
|
|
1504
1845
|
end
|
|
1505
1846
|
end
|
|
1847
|
+
|
|
1848
|
+
def export_from(message)
|
|
1849
|
+
return "you" if role_of(message) == :requester
|
|
1850
|
+
return "assistant" if assistant_message?(message)
|
|
1851
|
+
|
|
1852
|
+
"support"
|
|
1853
|
+
end
|
|
1506
1854
|
end
|
|
1507
1855
|
end
|