support_desk 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +68 -0
- data/README.md +998 -14
- data/app/assets/stylesheets/support_desk.css +10 -0
- data/app/controllers/support_desk/tickets_controller.rb +23 -1
- data/app/helpers/support_desk/engine_helper.rb +16 -0
- data/app/views/support_desk/console/tickets/_actions.html.erb +15 -0
- data/app/views/support_desk/console/tickets/_assignment.html.erb +16 -2
- data/app/views/support_desk/console/tickets/_composer.html.erb +27 -0
- data/app/views/support_desk/console/tickets/_context_card.html.erb +28 -0
- data/app/views/support_desk/console/tickets/_draft.html.erb +114 -0
- data/app/views/support_desk/console/tickets/_message.html.erb +34 -1
- data/app/views/support_desk/console/tickets/_ticket_row.html.erb +20 -0
- data/app/views/support_desk/console/tickets/_timeline.html.erb +68 -14
- data/app/views/support_desk/console/tickets/show.html.erb +5 -0
- data/app/views/support_desk/tickets/_human_door.html.erb +24 -0
- data/app/views/support_desk/tickets/_ticket_row.html.erb +13 -0
- data/config/locales/support_desk.console.en.yml +63 -0
- data/config/locales/support_desk.console.es.yml +65 -0
- data/config/locales/support_desk.en.yml +15 -0
- data/config/locales/support_desk.es.yml +23 -0
- data/config/routes.rb +7 -1
- data/lib/generators/support_desk/assistant_generator.rb +193 -0
- data/lib/generators/support_desk/install_generator.rb +12 -0
- data/lib/generators/support_desk/templates/add_assistants_to_support_desk.rb.erb +236 -0
- data/lib/generators/support_desk/templates/assistant/service.rb.erb +60 -0
- data/lib/generators/support_desk/templates/assistant/turn_job.rb.erb +72 -0
- data/lib/generators/support_desk/templates/assistant/turn_job_test.rb.erb +69 -0
- data/lib/generators/support_desk/templates/initializer.rb +33 -0
- data/lib/generators/support_desk/upgrade_generator.rb +12 -2
- data/lib/support_desk/assistant_policy.rb +213 -0
- data/lib/support_desk/brief.rb +283 -0
- data/lib/support_desk/configuration.rb +552 -2
- data/lib/support_desk/console.rb +290 -8
- data/lib/support_desk/context_card.rb +10 -1
- data/lib/support_desk/doctor.rb +144 -1
- data/lib/support_desk/engine.rb +10 -0
- data/lib/support_desk/errors.rb +37 -0
- data/lib/support_desk/events.rb +12 -5
- data/lib/support_desk/macros.rb +14 -1
- data/lib/support_desk/models/assistant.rb +153 -0
- data/lib/support_desk/models/concerns/requester.rb +18 -0
- data/lib/support_desk/models/desk.rb +26 -3
- data/lib/support_desk/models/draft.rb +266 -0
- data/lib/support_desk/models/event.rb +15 -1
- data/lib/support_desk/models/ticket/assistance.rb +675 -0
- data/lib/support_desk/models/ticket.rb +334 -37
- data/lib/support_desk/outcome.rb +38 -0
- data/lib/support_desk/queue.rb +41 -5
- data/lib/support_desk/test_helpers.rb +152 -0
- data/lib/support_desk/timeline.rb +40 -11
- data/lib/support_desk/topic.rb +22 -0
- data/lib/support_desk/topic_tree.rb +7 -1
- data/lib/support_desk/transcript.rb +237 -0
- data/lib/support_desk/version.rb +1 -1
- data/lib/support_desk.rb +108 -0
- data/lib/tasks/support_desk.rake +46 -0
- metadata +30 -8
|
@@ -61,6 +61,12 @@ module SupportDesk
|
|
|
61
61
|
has_many :events, class_name: "SupportDesk::Event", inverse_of: :ticket, dependent: :delete_all
|
|
62
62
|
has_many :messages, through: :conversation, source: :messages
|
|
63
63
|
|
|
64
|
+
# The assistant surface — the turn, the policy gates, her two verbs and
|
|
65
|
+
# the two exits — in its own file. The GATES inside the verbs below stay
|
|
66
|
+
# here, next to the verbs they guard: a reader looking at `close!` has to
|
|
67
|
+
# see what stops a machine closing a case.
|
|
68
|
+
include Assistance
|
|
69
|
+
|
|
64
70
|
# Persist only the path. Resolving behavior needs this ticket's desk;
|
|
65
71
|
# an attribute caster has no record context and cannot choose the tree.
|
|
66
72
|
attribute :topic, Topic::Type.new
|
|
@@ -224,12 +230,14 @@ module SupportDesk
|
|
|
224
230
|
# something to infer from Current.actor, and an agent passed as their
|
|
225
231
|
# own requester is asking for help, not writing to themselves.
|
|
226
232
|
by_support = !same_actor?(opener, requester)
|
|
227
|
-
ensure_opener!(opener) if by_support
|
|
233
|
+
ensure_opener!(opener, desk) if by_support
|
|
228
234
|
|
|
229
235
|
# The subject is checked BEFORE the topic: "this isn't supportable" is
|
|
230
236
|
# the useful error, and an unsupportable record has no topic to find.
|
|
231
237
|
validate_subject!(about, requester)
|
|
232
238
|
node = resolve_topic!(topic, about, desk, requester, about, by_support: by_support)
|
|
239
|
+
# The topic is what caps her, so the cap is checked once it is known.
|
|
240
|
+
ensure_assistant_may_open!(opener, requester, desk, node) if by_support && SupportDesk.ai_actor?(opener)
|
|
233
241
|
|
|
234
242
|
cardinality = cardinality_key_for(requester: requester, subject: about, topic: node)
|
|
235
243
|
existing = existing_for(requester: requester, desk: desk, subject: about, topic: node)
|
|
@@ -362,7 +370,7 @@ module SupportDesk
|
|
|
362
370
|
# Automation (`by: :system`) is refused by name rather than by
|
|
363
371
|
# NoMethodError three frames in — it is deferred work, not a typo (see
|
|
364
372
|
# docs/12-open-questions.md Q17).
|
|
365
|
-
def ensure_opener!(opener)
|
|
373
|
+
def ensure_opener!(opener, desk)
|
|
366
374
|
if opener.is_a?(Symbol)
|
|
367
375
|
raise NotAnAgent, "can't open a ticket as #{opener.inspect} — a case is opened by somebody who can " \
|
|
368
376
|
"answer it, and automation openers aren't supported yet; pass an agent record"
|
|
@@ -371,6 +379,37 @@ module SupportDesk
|
|
|
371
379
|
raise NotAnAgent, "can't open a ticket as an unsaved #{opener.class} — save the agent first"
|
|
372
380
|
end
|
|
373
381
|
ensure_agent_record!(opener)
|
|
382
|
+
return unless SupportDesk.ai_actor?(opener)
|
|
383
|
+
|
|
384
|
+
# Writing to somebody who never wrote to you is the one thing an
|
|
385
|
+
# assistant does that nobody asked for, so it takes its own
|
|
386
|
+
# permission on top of everything else.
|
|
387
|
+
unless opener.is_a?(SupportDesk::Assistant) && same_actor?(opener, desk.assistant)
|
|
388
|
+
raise NotAnAssistant,
|
|
389
|
+
"only desk #{desk.key}'s own assistant can open a case, and only when she may"
|
|
390
|
+
end
|
|
391
|
+
unless opener.may_open_conversations?
|
|
392
|
+
raise AssistantNotAllowed.new(nil, verb: :open,
|
|
393
|
+
message: "#{opener.key} may not open conversations — set " \
|
|
394
|
+
"`may_open_conversations = true` if that is the intent")
|
|
395
|
+
end
|
|
396
|
+
return if AssistantPolicy::RANK.fetch(opener.autonomy) >= AssistantPolicy::RANK.fetch(:reply)
|
|
397
|
+
|
|
398
|
+
raise AssistantNotAllowed.new(nil, verb: :open,
|
|
399
|
+
message: "#{opener.key} works at #{opener.autonomy}: opening a " \
|
|
400
|
+
"case means speaking first, which takes :reply")
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
# The topic's cap and the host's `cap` block, asked of the case this is
|
|
404
|
+
# ABOUT to be. Her per-case budget is not checked here: a brand new
|
|
405
|
+
# case has spent none of it, so the only way it could refuse is
|
|
406
|
+
# `max_turns 0`, and the policy below covers her level anyway.
|
|
407
|
+
def ensure_assistant_may_open!(opener, requester, desk, node)
|
|
408
|
+
candidate = new(desk: desk, requester: requester, topic: node)
|
|
409
|
+
policy = AssistantPolicy.for(candidate, opener)
|
|
410
|
+
return if policy.may_reply?
|
|
411
|
+
|
|
412
|
+
raise AssistantNotAllowed.new(policy, verb: :open)
|
|
374
413
|
end
|
|
375
414
|
|
|
376
415
|
def describe_record(record)
|
|
@@ -560,9 +599,12 @@ module SupportDesk
|
|
|
560
599
|
# subscriber then finds the message already registered and does
|
|
561
600
|
# nothing.
|
|
562
601
|
def post_opening!(ticket, message, files:, by:, by_support:)
|
|
563
|
-
|
|
602
|
+
notices = [ post_opening_line!(ticket) ]
|
|
564
603
|
|
|
565
|
-
posted = if by_support
|
|
604
|
+
posted = if by_support && SupportDesk.ai_actor?(by)
|
|
605
|
+
notices << ticket.send(:post_disclosure_notice!, by) if by.notice?
|
|
606
|
+
ticket.send(:post_assistant_opening!, message, files: files, assistant: by)
|
|
607
|
+
elsif by_support
|
|
566
608
|
# Never the inbound "no message, hand the ticket back" shortcut: a
|
|
567
609
|
# desk that writes first with nothing to say is a chats validation
|
|
568
610
|
# error, and this whole transaction goes with it.
|
|
@@ -572,7 +614,8 @@ module SupportDesk
|
|
|
572
614
|
end
|
|
573
615
|
|
|
574
616
|
ticket.send(:record_registration!, posted) if posted
|
|
575
|
-
|
|
617
|
+
ticket.send(:stamp_assistant_action!) if by_support && SupportDesk.ai_actor?(by)
|
|
618
|
+
pin_opening_lines!(ticket, notices, posted)
|
|
576
619
|
ticket
|
|
577
620
|
end
|
|
578
621
|
|
|
@@ -595,11 +638,16 @@ module SupportDesk
|
|
|
595
638
|
# Only this new notice moves, never anybody's real message, and the
|
|
596
639
|
# human message still owns the conversation's last-message pointer, so
|
|
597
640
|
# nothing has to be recomputed afterwards.
|
|
598
|
-
def
|
|
599
|
-
|
|
641
|
+
def pin_opening_lines!(ticket, notices, posted)
|
|
642
|
+
notices = Array(notices).compact
|
|
643
|
+
return if notices.empty?
|
|
600
644
|
|
|
601
645
|
anchor = posted&.created_at || ticket.opened_at
|
|
602
|
-
|
|
646
|
+
# Backwards, one tick each, so the last line written sits closest to
|
|
647
|
+
# the message and the first one opens the thread.
|
|
648
|
+
notices.reverse.each_with_index do |notice, index|
|
|
649
|
+
notice.update_columns(created_at: anchor - (ordering_tick * (index + 1)))
|
|
650
|
+
end
|
|
603
651
|
# When there IS a first message it owns the conversation's
|
|
604
652
|
# last-message pointer and nothing needs repairing. When there isn't,
|
|
605
653
|
# the notice is that pointer, and chats denormalised its timestamp
|
|
@@ -622,8 +670,12 @@ module SupportDesk
|
|
|
622
670
|
# running before any of it.
|
|
623
671
|
def reply_into!(ticket, message, files:, by:, by_support:, request:, authorize_reuse:)
|
|
624
672
|
if by_support
|
|
673
|
+
# `turn: :current` is for the assistant: reuse turns outreach into
|
|
674
|
+
# an ordinary reply, with her full rules — and the turn she has to
|
|
675
|
+
# hold is the one this lock reads, because there was never an
|
|
676
|
+
# earlier one to give her.
|
|
625
677
|
ticket.send(:reply_under_lock!, message, by: by, files: files, request: request,
|
|
626
|
-
authorize: authorize_reuse)
|
|
678
|
+
authorize: authorize_reuse, turn: :current)
|
|
627
679
|
else
|
|
628
680
|
ticket.with_lock(requires_new: true) do
|
|
629
681
|
authorize_reuse&.call(ticket)
|
|
@@ -803,21 +855,35 @@ module SupportDesk
|
|
|
803
855
|
# by whoever answers first and a drop-in on somebody else's ticket is
|
|
804
856
|
# recorded; under :take_over the drop-in takes it; under :assignee_only
|
|
805
857
|
# it raises SupportDesk::NotAllowed. Returns the Chats::Message.
|
|
806
|
-
|
|
858
|
+
#
|
|
859
|
+
# `by:` the assistant needs `turn:` and her policy's permission — see
|
|
860
|
+
# SupportDesk::Ticket::Assistance. `metadata:` rides on the message
|
|
861
|
+
# (provenance for a draft a human sent; hosts nest their own under
|
|
862
|
+
# "host").
|
|
863
|
+
def reply!(body = nil, by: nil, files: [], request: nil, metadata: {}, turn: nil)
|
|
807
864
|
actor = resolve_actor(by)
|
|
808
865
|
ensure_agent!(actor)
|
|
809
866
|
|
|
810
|
-
reply_under_lock!(body, by: actor, files: files, request: request)
|
|
867
|
+
reply_under_lock!(body, by: actor, files: files, request: request, metadata: metadata, turn: turn)
|
|
811
868
|
end
|
|
812
869
|
|
|
813
870
|
# An internal note: in the timeline and the console, never in the
|
|
814
871
|
# conversation, never mirrored to any channel. Returns the Event.
|
|
815
|
-
def note!(body, by: nil, request: nil)
|
|
872
|
+
def note!(body, by: nil, request: nil, turn: nil)
|
|
816
873
|
actor = resolve_actor(by)
|
|
817
874
|
ensure_agent!(actor)
|
|
875
|
+
assistant = (resolve_assistant!(actor) if SupportDesk.ai_actor?(actor))
|
|
818
876
|
raise ArgumentError, "a note needs something to say" if body.blank?
|
|
819
877
|
|
|
820
|
-
event = write_transition!(:note, actor: actor, request: request)
|
|
878
|
+
event = write_transition!(:note, actor: actor, request: request) do
|
|
879
|
+
if assistant
|
|
880
|
+
ensure_current_turn!(turn)
|
|
881
|
+
policy = assistant_policy(assistant)
|
|
882
|
+
raise AssistantNotAllowed.new(policy, verb: :note) unless policy.may_observe?
|
|
883
|
+
end
|
|
884
|
+
{ "note" => body.to_s }
|
|
885
|
+
end
|
|
886
|
+
stamp_assistant_action! if assistant && event
|
|
821
887
|
SupportDesk.emit_after_commit(:note_added, self, event) if event
|
|
822
888
|
event
|
|
823
889
|
end
|
|
@@ -828,9 +894,14 @@ module SupportDesk
|
|
|
828
894
|
def assign!(to:, by: nil, reason: nil, note: nil, request: nil)
|
|
829
895
|
actor = resolve_actor(by)
|
|
830
896
|
ensure_agent!(actor)
|
|
897
|
+
ensure_assistant_may_assign!(actor, to)
|
|
831
898
|
ensure_assignable!(to)
|
|
832
899
|
|
|
833
900
|
reason ||= to == actor ? :taken : :assigned
|
|
901
|
+
# A PERSON giving the case back to the assistant is the one action that
|
|
902
|
+
# lifts the three human-side flags — and it says so out loud, in the
|
|
903
|
+
# payload, because nothing else may clear them (I9).
|
|
904
|
+
handing_back = SupportDesk.ai_actor?(to) && !SupportDesk.ai_actor?(actor)
|
|
834
905
|
assignment = nil
|
|
835
906
|
|
|
836
907
|
event = write_transition!(:assigned, actor: actor, request: request) do
|
|
@@ -838,13 +909,20 @@ module SupportDesk
|
|
|
838
909
|
next false if assigned_to?(to)
|
|
839
910
|
|
|
840
911
|
assignment = Assignment.open!(ticket: self, agent: to, by: actor, reason: reason, note: note)
|
|
841
|
-
|
|
842
|
-
|
|
912
|
+
attributes = { assignee: to }
|
|
913
|
+
if handing_back
|
|
914
|
+
attributes.merge!(human_required_at: nil, human_required_reason: nil, assistant_paused_at: nil,
|
|
915
|
+
assistant_paused_reason: nil, assistant_cap: nil)
|
|
916
|
+
end
|
|
917
|
+
update!(attributes)
|
|
918
|
+
{ "assignee" => SupportDesk.actor_key(to), "reason" => reason.to_s,
|
|
919
|
+
"handed_back" => (true if handing_back) }.compact
|
|
843
920
|
end
|
|
844
921
|
return self unless event
|
|
845
922
|
|
|
846
923
|
announce_assignment!(to, first: assignments.count <= 1)
|
|
847
924
|
SupportDesk.emit_after_commit(:ticket_assigned, self, assignment)
|
|
925
|
+
emit_assistant_turn if handing_back && awaiting_reply?
|
|
848
926
|
self
|
|
849
927
|
end
|
|
850
928
|
|
|
@@ -853,7 +931,13 @@ module SupportDesk
|
|
|
853
931
|
def hand_off!(to:, note: nil, by: nil, request: nil)
|
|
854
932
|
actor = resolve_actor(by)
|
|
855
933
|
ensure_agent!(actor)
|
|
934
|
+
if SupportDesk.ai_actor?(actor)
|
|
935
|
+
# She does not choose who picks a case up. `escalate!` is her way
|
|
936
|
+
# out: it asks for a person rather than naming one.
|
|
937
|
+
raise AssistantNotAllowed.new(assistant_policy(resolve_assistant!(actor)), verb: :hand_off)
|
|
938
|
+
end
|
|
856
939
|
ensure_assignable!(to)
|
|
940
|
+
handing_back = SupportDesk.ai_actor?(to)
|
|
857
941
|
from = nil
|
|
858
942
|
assignment = nil
|
|
859
943
|
event = write_transition!(:handed_off, actor: actor, request: request) do
|
|
@@ -867,13 +951,20 @@ module SupportDesk
|
|
|
867
951
|
from = assignee
|
|
868
952
|
assignment = Assignment.open!(ticket: self, agent: to, by: actor, reason: :handed_off, note: note,
|
|
869
953
|
release_reason: :handed_off)
|
|
870
|
-
|
|
871
|
-
|
|
954
|
+
attributes = { assignee: to }
|
|
955
|
+
if handing_back
|
|
956
|
+
attributes.merge!(human_required_at: nil, human_required_reason: nil, assistant_paused_at: nil,
|
|
957
|
+
assistant_paused_reason: nil, assistant_cap: nil)
|
|
958
|
+
end
|
|
959
|
+
update!(attributes)
|
|
960
|
+
{ "from" => SupportDesk.actor_key(from), "to" => SupportDesk.actor_key(to), "note" => note,
|
|
961
|
+
"handed_back" => (true if handing_back) }
|
|
872
962
|
end
|
|
873
963
|
return self unless event
|
|
874
964
|
|
|
875
965
|
announce_assignment!(to, first: false)
|
|
876
966
|
SupportDesk.emit_after_commit(:ticket_handed_off, self, assignment, from: from, note: note)
|
|
967
|
+
emit_assistant_turn if handing_back && awaiting_reply?
|
|
877
968
|
self
|
|
878
969
|
end
|
|
879
970
|
|
|
@@ -881,9 +972,17 @@ module SupportDesk
|
|
|
881
972
|
def release!(by: nil, reason: :released, request: nil)
|
|
882
973
|
actor = resolve_actor(by)
|
|
883
974
|
ensure_agent!(actor)
|
|
975
|
+
assistant = (resolve_assistant!(actor) if SupportDesk.ai_actor?(actor))
|
|
884
976
|
from = nil
|
|
885
977
|
event = write_transition!(:released, actor: actor, request: request) do
|
|
886
978
|
raise InvalidTransition, "can't release a closed ticket" if closed?
|
|
979
|
+
if assistant
|
|
980
|
+
policy = assistant_policy(assistant)
|
|
981
|
+
raise AssistantNotAllowed.new(policy, verb: :release) unless policy.may_observe?
|
|
982
|
+
# Her own seat, and only hers: putting a PERSON's case back in the
|
|
983
|
+
# pile is not something a machine gets to decide.
|
|
984
|
+
raise AssistantNotAllowed.new(policy, verb: :release) unless assigned_to?(assistant)
|
|
985
|
+
end
|
|
887
986
|
next false if unassigned?
|
|
888
987
|
|
|
889
988
|
from = assignee
|
|
@@ -898,17 +997,32 @@ module SupportDesk
|
|
|
898
997
|
end
|
|
899
998
|
|
|
900
999
|
# Close the case. Closing a closed ticket is a no-op, not an error.
|
|
901
|
-
def close!(by: nil, request: nil)
|
|
1000
|
+
def close!(by: nil, request: nil, turn: nil)
|
|
902
1001
|
actor = resolve_actor(by)
|
|
903
1002
|
ensure_agent!(actor)
|
|
1003
|
+
assistant = (resolve_assistant!(actor) if SupportDesk.ai_actor?(actor))
|
|
904
1004
|
|
|
905
1005
|
event = write_transition!(:closed, actor: actor, request: request) do
|
|
1006
|
+
if assistant
|
|
1007
|
+
reconcile_unregistered_messages!
|
|
1008
|
+
ensure_current_turn!(turn)
|
|
1009
|
+
policy = assistant_policy(assistant)
|
|
1010
|
+
# Four conditions, and every one of them is somebody else's word:
|
|
1011
|
+
# her level allows it, she is the one holding the case, the
|
|
1012
|
+
# customer has the last word (so nothing is waiting for an answer)
|
|
1013
|
+
# and nobody has asked for a person.
|
|
1014
|
+
raise AssistantNotAllowed.new(policy, verb: :close) unless policy.may_close?
|
|
1015
|
+
raise AssistantNotAllowed.new(policy, verb: :close) unless assigned_to?(assistant)
|
|
1016
|
+
raise AssistantNotAllowed.new(policy, verb: :close) unless awaiting_requester?
|
|
1017
|
+
raise AssistantNotAllowed.new(policy, verb: :close) if human_required?
|
|
1018
|
+
end
|
|
906
1019
|
next false if closed?
|
|
907
1020
|
|
|
1021
|
+
expired = expire_pending_drafts!
|
|
908
1022
|
assignments.open.each { |assignment| assignment.release!(reason: :closed) }
|
|
909
1023
|
update!(status: "closed", closed_at: Time.current, closed_by: record_actor(actor),
|
|
910
1024
|
awaiting: "none", waiting_since: nil)
|
|
911
|
-
{}
|
|
1025
|
+
{ "expired_drafts" => (expired if expired.positive?) }
|
|
912
1026
|
end
|
|
913
1027
|
return self unless event
|
|
914
1028
|
|
|
@@ -924,10 +1038,17 @@ module SupportDesk
|
|
|
924
1038
|
# the pool.
|
|
925
1039
|
def reopen!(by: nil, request: nil)
|
|
926
1040
|
actor = resolve_actor(by)
|
|
1041
|
+
if SupportDesk.ai_actor?(actor)
|
|
1042
|
+
raise AssistantNotAllowed.new(assistant_policy(resolve_assistant!(actor)), verb: :reopen)
|
|
1043
|
+
end
|
|
927
1044
|
|
|
928
1045
|
event = write_transition!(:reopened, actor: actor, request: request) do
|
|
929
1046
|
next false unless closed?
|
|
930
1047
|
|
|
1048
|
+
# Read BEFORE the update clears it: whether the case we are bringing
|
|
1049
|
+
# back is one the assistant closed is the whole question behind the
|
|
1050
|
+
# cap below, and `closed_by` is where it is written down.
|
|
1051
|
+
closed_by_assistant = closed_by_type == SupportDesk::Assistant.polymorphic_name
|
|
931
1052
|
# Order matters: waiting_since is DERIVED from awaiting, so awaiting
|
|
932
1053
|
# has to be the reopened value before it is read. Computing both in
|
|
933
1054
|
# one update! hash reads the closed ticket's "none" and stores nil —
|
|
@@ -937,8 +1058,8 @@ module SupportDesk
|
|
|
937
1058
|
cardinality_key: "reopened:#{id}")
|
|
938
1059
|
self.waiting_since = waiting_since_from_clocks
|
|
939
1060
|
save!
|
|
940
|
-
restore_assignment!(by: actor)
|
|
941
|
-
{ "reopen_count" => reopen_count }
|
|
1061
|
+
capped = restore_assignment!(by: actor, closed_by_assistant: closed_by_assistant)
|
|
1062
|
+
{ "reopen_count" => reopen_count, "assistant_capped" => (true if capped) }.compact
|
|
942
1063
|
end
|
|
943
1064
|
return self unless event
|
|
944
1065
|
|
|
@@ -953,6 +1074,12 @@ module SupportDesk
|
|
|
953
1074
|
# Agents may file onto any topic in the tree, including ones no
|
|
954
1075
|
# requester is offered (`only:`); requesters may not file at all.
|
|
955
1076
|
ensure_agent!(actor)
|
|
1077
|
+
# Triage is where authority comes from — a topic decides the cap she
|
|
1078
|
+
# works under, so refiling her own case would be widening her own
|
|
1079
|
+
# policy (I10). 0.3 refuses it outright.
|
|
1080
|
+
if SupportDesk.ai_actor?(actor)
|
|
1081
|
+
raise AssistantNotAllowed.new(assistant_policy(resolve_assistant!(actor)), verb: :triage)
|
|
1082
|
+
end
|
|
956
1083
|
node = desk_config.topics.find(to.to_s) ||
|
|
957
1084
|
raise(UnknownTopic, "no topic #{to.inspect} on desk #{desk.key}")
|
|
958
1085
|
|
|
@@ -962,7 +1089,11 @@ module SupportDesk
|
|
|
962
1089
|
|
|
963
1090
|
update!(topic: node, priority: [ priority.to_i, node.priority ].max,
|
|
964
1091
|
cardinality_key: recomputed_cardinality_key(subject: subject, topic: node))
|
|
965
|
-
|
|
1092
|
+
# A refile onto a capped topic is a refile onto a case she may no
|
|
1093
|
+
# longer answer, so her seat goes with it — in the same transition,
|
|
1094
|
+
# so the queue never shows a machine holding a case it can't work.
|
|
1095
|
+
released = release_assistant_if_unfit!
|
|
1096
|
+
{ "from" => from&.path, "to" => node.path, "assistant_released" => (true if released) }
|
|
966
1097
|
end
|
|
967
1098
|
return self unless event
|
|
968
1099
|
|
|
@@ -974,6 +1105,11 @@ module SupportDesk
|
|
|
974
1105
|
def attach_subject!(record, by: nil, request: nil)
|
|
975
1106
|
actor = resolve_actor(by)
|
|
976
1107
|
ensure_agent!(actor)
|
|
1108
|
+
# Same reason as `change_topic!`: what a case is ABOUT decides what may
|
|
1109
|
+
# be done on it, so a machine does not get to say.
|
|
1110
|
+
if SupportDesk.ai_actor?(actor)
|
|
1111
|
+
raise AssistantNotAllowed.new(assistant_policy(resolve_assistant!(actor)), verb: :triage)
|
|
1112
|
+
end
|
|
977
1113
|
unless record.respond_to?(:supportable?) && record.supportable?
|
|
978
1114
|
raise NotSupportable, "#{record.class} isn't supportable — add `supportable topic: :something` to it"
|
|
979
1115
|
end
|
|
@@ -1052,6 +1188,9 @@ module SupportDesk
|
|
|
1052
1188
|
# test instead of quietly moving the boundary.
|
|
1053
1189
|
def actions_for(agent)
|
|
1054
1190
|
return [] unless agent.respond_to?(:support_agent?) && agent.support_agent?
|
|
1191
|
+
# A machine's verbs are its policy's, filtered by the same state. Same
|
|
1192
|
+
# boundary, different vocabulary — see #assistant_actions_for.
|
|
1193
|
+
return assistant_actions_for(agent) if SupportDesk.ai_actor?(agent)
|
|
1055
1194
|
# Off duty is a real answer: the console can still show the case, and
|
|
1056
1195
|
# an agent passing by can still leave a note, but nothing that speaks
|
|
1057
1196
|
# to the requester is offered to somebody who isn't working.
|
|
@@ -1070,6 +1209,11 @@ module SupportDesk
|
|
|
1070
1209
|
actions << :release if assigned?
|
|
1071
1210
|
actions << :change_topic
|
|
1072
1211
|
actions << :close
|
|
1212
|
+
if pending_draft
|
|
1213
|
+
actions << :send_draft if may_reply?(agent) && !requester_unavailable?
|
|
1214
|
+
actions << :reject_draft
|
|
1215
|
+
end
|
|
1216
|
+
actions << (assistant_paused? ? :resume_assistant : :pause_assistant) if assistant
|
|
1073
1217
|
end
|
|
1074
1218
|
actions
|
|
1075
1219
|
end
|
|
@@ -1077,6 +1221,10 @@ module SupportDesk
|
|
|
1077
1221
|
# Whether +agent+ may answer right now under this desk's reply policy.
|
|
1078
1222
|
def may_reply?(agent)
|
|
1079
1223
|
return false unless agent.respond_to?(:support_agent?) && agent.support_agent?
|
|
1224
|
+
# A case a machine is holding is a case any person may answer,
|
|
1225
|
+
# whatever the desk says about assignees: `:assignee_only` exists so
|
|
1226
|
+
# two people don't answer at once, and she is not one (I8).
|
|
1227
|
+
return true if held_by_assistant? && !SupportDesk.ai_actor?(agent)
|
|
1080
1228
|
return true unless desk_config.reply_policy == :assignee_only
|
|
1081
1229
|
|
|
1082
1230
|
assigned_to?(agent)
|
|
@@ -1086,9 +1234,11 @@ module SupportDesk
|
|
|
1086
1234
|
# the whole on-duty pool while it's unheld. The gem computes it; the
|
|
1087
1235
|
# host delivers it.
|
|
1088
1236
|
def agents_to_notify
|
|
1089
|
-
|
|
1237
|
+
# A case the assistant holds is a case no person has seen, so the pool
|
|
1238
|
+
# hears about it: notifying a machine is notifying nobody.
|
|
1239
|
+
return [ assignee ].compact if assigned? && !held_by_assistant?
|
|
1090
1240
|
|
|
1091
|
-
desk.on_duty_agents.to_a
|
|
1241
|
+
desk.on_duty_agents.to_a.reject { |agent| SupportDesk.ai_actor?(agent) }
|
|
1092
1242
|
end
|
|
1093
1243
|
|
|
1094
1244
|
# Generic on purpose: a lock screen shouldn't spell out what somebody's
|
|
@@ -1132,8 +1282,15 @@ module SupportDesk
|
|
|
1132
1282
|
# Post a message as the DESK, signed by the agent who wrote it — every
|
|
1133
1283
|
# answer, and the desk's first word when it writes first. One place knows
|
|
1134
1284
|
# "the desk sends, the human signs".
|
|
1135
|
-
|
|
1136
|
-
|
|
1285
|
+
# `metadata:` is the provenance a message carries: who drafted it, what
|
|
1286
|
+
# policy allowed it, what the machine declared about it. Posted through
|
|
1287
|
+
# `messages.create!` rather than `desk.message!` for one reason only —
|
|
1288
|
+
# chats' sugar takes no metadata, and the validations are the same.
|
|
1289
|
+
def post_agent_message!(body, files: [], by:, metadata: {}) # :nodoc:
|
|
1290
|
+
attributes = { sender: desk, body: body, author: by }
|
|
1291
|
+
attributes[:files] = files if files.present?
|
|
1292
|
+
attributes[:metadata] = metadata if metadata.present?
|
|
1293
|
+
conversation.messages.create!(**attributes)
|
|
1137
1294
|
end
|
|
1138
1295
|
|
|
1139
1296
|
def inspect
|
|
@@ -1170,10 +1327,44 @@ module SupportDesk
|
|
|
1170
1327
|
end
|
|
1171
1328
|
|
|
1172
1329
|
ensure_agent!(agent)
|
|
1330
|
+
return unless SupportDesk.ai_actor?(agent)
|
|
1331
|
+
|
|
1332
|
+
# "Could she hold this case once the human-side flags were lifted?" —
|
|
1333
|
+
# the hand-back question, so the very flags a hand-back exists to
|
|
1334
|
+
# clear are not what refuses it. Her autonomy, the topic and the host's
|
|
1335
|
+
# cap block still decide.
|
|
1336
|
+
policy = assistant_policy(agent, hand_back: true)
|
|
1337
|
+
raise AssistantNotAllowed.new(policy, verb: :take) unless policy.may_hold?
|
|
1338
|
+
end
|
|
1339
|
+
|
|
1340
|
+
# An assistant may take a case, and that is all: only herself, only when
|
|
1341
|
+
# nobody holds it, and only at a level that may answer.
|
|
1342
|
+
def ensure_assistant_may_assign!(actor, to)
|
|
1343
|
+
return unless SupportDesk.ai_actor?(actor)
|
|
1344
|
+
|
|
1345
|
+
assistant = resolve_assistant!(actor)
|
|
1346
|
+
policy = assistant_policy(assistant)
|
|
1347
|
+
unless self.class.same_actor?(actor, to)
|
|
1348
|
+
raise AssistantNotAllowed.new(policy, verb: :assign,
|
|
1349
|
+
message: "#{assistant.key} may not give #{reference} to " \
|
|
1350
|
+
"anybody — an assistant can only take a case herself")
|
|
1351
|
+
end
|
|
1352
|
+
raise AssistantNotAllowed.new(policy, verb: :take) unless unassigned?
|
|
1353
|
+
raise AssistantNotAllowed.new(policy, verb: :take) unless policy.may_hold?
|
|
1173
1354
|
end
|
|
1174
1355
|
|
|
1175
1356
|
def ensure_agent!(actor)
|
|
1176
1357
|
return if actor.is_a?(Symbol)
|
|
1358
|
+
# An AI-kind actor that is not a SupportDesk::Assistant is refused on
|
|
1359
|
+
# every write, by it or to it (I2). A host model declared `kind: :ai`
|
|
1360
|
+
# is a machine the gem knows nothing about, and treating it as a human
|
|
1361
|
+
# agent — which is what 0.2 did — hands it every human's authority.
|
|
1362
|
+
if SupportDesk.ai_actor?(actor) && !actor.is_a?(SupportDesk::Assistant)
|
|
1363
|
+
raise NotAnAssistant,
|
|
1364
|
+
"#{describe_actor(actor)} is declared `acts_as_support_agent kind: :ai` but isn't this gem's " \
|
|
1365
|
+
"assistant — configure one with `config.assistant` and act as SupportDesk.assistant(key)"
|
|
1366
|
+
end
|
|
1367
|
+
|
|
1177
1368
|
self.class.ensure_agent_record!(actor)
|
|
1178
1369
|
end
|
|
1179
1370
|
|
|
@@ -1211,17 +1402,48 @@ module SupportDesk
|
|
|
1211
1402
|
# `authorize:` is the console's hook (see Ticket.open_or_reply!): it runs
|
|
1212
1403
|
# under this lock, before any policy side effect, and a raise there rolls
|
|
1213
1404
|
# the whole thing back.
|
|
1214
|
-
def reply_under_lock!(body, by:, files:, request:, authorize: nil)
|
|
1405
|
+
def reply_under_lock!(body, by:, files:, request:, authorize: nil, metadata: {}, turn: nil)
|
|
1215
1406
|
with_lock(requires_new: true) do
|
|
1216
1407
|
authorize&.call(self)
|
|
1408
|
+
next assistant_reply_under_lock!(body, assistant: by, files: files, request: request,
|
|
1409
|
+
metadata: metadata, turn: turn) if SupportDesk.ai_actor?(by)
|
|
1410
|
+
|
|
1217
1411
|
ensure_writable!
|
|
1218
1412
|
apply_reply_policy!(by, request: request)
|
|
1219
|
-
posted = post_agent_message!(body, files: files, by: by)
|
|
1413
|
+
posted = post_agent_message!(body, files: files, by: by, metadata: metadata)
|
|
1414
|
+
# A person answering makes every machine proposal on this case out of
|
|
1415
|
+
# date — including one they are about to send, which is why the draft
|
|
1416
|
+
# being sent says so and is left alone.
|
|
1417
|
+
supersede_pending_drafts!(except: metadata.dig("support_desk", "draft_id"))
|
|
1220
1418
|
record_registration!(posted)
|
|
1221
1419
|
posted
|
|
1222
1420
|
end
|
|
1223
1421
|
end
|
|
1224
1422
|
|
|
1423
|
+
# `reply!` by the assistant: her full rules, under the lock the caller
|
|
1424
|
+
# already holds. Everything here is also what `respond!` runs — this is
|
|
1425
|
+
# the path for a host that decided to answer rather than ask policy.
|
|
1426
|
+
def assistant_reply_under_lock!(body, assistant:, files:, request:, metadata:, turn:)
|
|
1427
|
+
assistant = resolve_assistant!(assistant)
|
|
1428
|
+
# `:current` means "the turn as this lock sees it" — the one place the
|
|
1429
|
+
# gem supplies a turn itself, because an outreach reply into an
|
|
1430
|
+
# existing case has no earlier turn for anybody to have held.
|
|
1431
|
+
turn = assistant_turn if turn == :current
|
|
1432
|
+
reconcile_unregistered_messages!
|
|
1433
|
+
ensure_current_turn!(turn)
|
|
1434
|
+
ensure_writable!
|
|
1435
|
+
|
|
1436
|
+
policy = assistant_policy(assistant)
|
|
1437
|
+
raise AssistantNotAllowed.new(policy, verb: :reply) unless policy.may_reply?
|
|
1438
|
+
raise AssistantNotAllowed.new(policy, verb: :not_your_turn) unless awaiting_reply?
|
|
1439
|
+
|
|
1440
|
+
left = assistant_turns_left
|
|
1441
|
+
raise AssistantNotAllowed.new(policy, verb: :max_turns) if left&.zero?
|
|
1442
|
+
|
|
1443
|
+
speak!(body, assistant, policy: policy, turn: turn, files: files, confidence: nil, sources: [],
|
|
1444
|
+
metadata: metadata, request: request)
|
|
1445
|
+
end
|
|
1446
|
+
|
|
1225
1447
|
def apply_reply_policy!(actor, request: nil)
|
|
1226
1448
|
# A closed case has no seat to take: an agent adding one last word
|
|
1227
1449
|
# posts it and the case stays closed. (A REQUESTER writing is what
|
|
@@ -1238,6 +1460,17 @@ module SupportDesk
|
|
|
1238
1460
|
|
|
1239
1461
|
assign!(to: actor, by: actor, request: request)
|
|
1240
1462
|
elsif !assigned_to?(actor)
|
|
1463
|
+
# Humans outrank assistants, under EVERY reply policy (I8). A person
|
|
1464
|
+
# answering a case a machine is holding takes it over: there is
|
|
1465
|
+
# nothing to ask about "who owns this" when one of the two can't
|
|
1466
|
+
# want it, and leaving her seated would keep her answering next.
|
|
1467
|
+
if held_by_assistant? && !SupportDesk.ai_actor?(actor)
|
|
1468
|
+
return assign!(to: actor, by: actor, reason: :drop_in_takeover, request: request)
|
|
1469
|
+
end
|
|
1470
|
+
# Belt: the `held_by_human` floor already turned this into a draft,
|
|
1471
|
+
# so an assistant reaching here is a bug, not a policy question.
|
|
1472
|
+
raise AssistantNotAllowed.new(assistant_policy(actor), verb: :reply) if SupportDesk.ai_actor?(actor)
|
|
1473
|
+
|
|
1241
1474
|
case policy
|
|
1242
1475
|
when :assignee_only
|
|
1243
1476
|
raise NotAllowed, "ticket #{reference} is held by #{describe_actor(assignee)} and this desk only " \
|
|
@@ -1270,7 +1503,11 @@ module SupportDesk
|
|
|
1270
1503
|
payload = yield
|
|
1271
1504
|
return nil if payload == false || payload.nil?
|
|
1272
1505
|
|
|
1273
|
-
Event.record!(ticket: self, kind: kind, actor: actor, payload: payload.compact)
|
|
1506
|
+
event = Event.record!(ticket: self, kind: kind, actor: actor, payload: payload.compact)
|
|
1507
|
+
# A real transition changed the case, so it moves the turn — a
|
|
1508
|
+
# no-op one wrote no event and moves nothing.
|
|
1509
|
+
bump_assistant_revision!
|
|
1510
|
+
event
|
|
1274
1511
|
end
|
|
1275
1512
|
|
|
1276
1513
|
# The half that tells the world, once the write is durable. Every
|
|
@@ -1288,6 +1525,10 @@ module SupportDesk
|
|
|
1288
1525
|
end
|
|
1289
1526
|
|
|
1290
1527
|
def announce_assignment!(agent, first:)
|
|
1528
|
+
# "Rose se ocupa de tu consulta" is a promise about a person. Her
|
|
1529
|
+
# disclosure line is her announcement, and it is the only one.
|
|
1530
|
+
return if SupportDesk.ai_actor?(agent)
|
|
1531
|
+
|
|
1291
1532
|
mode = desk_config.announce_assignments
|
|
1292
1533
|
return if mode == :never
|
|
1293
1534
|
return if mode == :first_only && !first
|
|
@@ -1321,10 +1562,16 @@ module SupportDesk
|
|
|
1321
1562
|
reopen_event = nil
|
|
1322
1563
|
|
|
1323
1564
|
attributes = { last_registered_message_id: message.id }
|
|
1565
|
+
closed_by_assistant = false
|
|
1324
1566
|
case role
|
|
1325
1567
|
when :requester
|
|
1326
1568
|
attributes[:last_requester_message_at] = message.created_at
|
|
1569
|
+
# The pointer the turn's reconciliation reads: which requester
|
|
1570
|
+
# message the clocks are standing on, by id and not only by time.
|
|
1571
|
+
attributes[:last_requester_message_id] = message.id
|
|
1327
1572
|
if closed? && message.created_at > closed_at && desk_config.closed_tickets == :reopen_on_reply
|
|
1573
|
+
# Read before the merge below clears it — see #reopen!.
|
|
1574
|
+
closed_by_assistant = closed_by_type == SupportDesk::Assistant.polymorphic_name
|
|
1328
1575
|
attributes.merge!(status: "open", closed_at: nil, closed_by: nil,
|
|
1329
1576
|
reopen_count: reopen_count.to_i + 1, cardinality_key: "reopened:#{id}")
|
|
1330
1577
|
reopened = true
|
|
@@ -1348,12 +1595,22 @@ module SupportDesk
|
|
|
1348
1595
|
self.awaiting = closed? ? "none" : awaiting_from_clocks
|
|
1349
1596
|
self.waiting_since = waiting_since_from_clocks
|
|
1350
1597
|
save!
|
|
1598
|
+
# Every registered message moves the case on, so every one of them
|
|
1599
|
+
# moves the turn: an assistant holding the turn she read a second ago
|
|
1600
|
+
# is holding a case that has not changed since (I4).
|
|
1601
|
+
bump_assistant_revision!
|
|
1351
1602
|
|
|
1352
1603
|
if reopened
|
|
1353
|
-
restore_assignment!(by: :system)
|
|
1354
|
-
reopen_event = record_transition!(:reopened, actor: requester)
|
|
1604
|
+
capped = restore_assignment!(by: :system, closed_by_assistant: closed_by_assistant)
|
|
1605
|
+
reopen_event = record_transition!(:reopened, actor: requester) do
|
|
1606
|
+
{ "via" => "requester_reply", "assistant_capped" => (true if capped) }.compact
|
|
1607
|
+
end
|
|
1355
1608
|
end
|
|
1356
1609
|
|
|
1610
|
+
# After the bump, and after any reopen: the hook decides about a case
|
|
1611
|
+
# in the state this message left it in.
|
|
1612
|
+
evaluate_hand_off_phrase!(message) if role == :requester
|
|
1613
|
+
|
|
1357
1614
|
publish_transition(reopen_event, :reopened, requester, nil) if reopen_event
|
|
1358
1615
|
announce_registration(message, role: role, opening: opening, reopened: reopened)
|
|
1359
1616
|
self
|
|
@@ -1372,12 +1629,26 @@ module SupportDesk
|
|
|
1372
1629
|
def registered?(message)
|
|
1373
1630
|
return true if last_registered_message_id.present? && last_registered_message_id.to_s == message.id.to_s
|
|
1374
1631
|
|
|
1375
|
-
|
|
1632
|
+
role = role_of(message)
|
|
1633
|
+
clock = case role
|
|
1376
1634
|
when :requester then last_requester_message_at
|
|
1377
1635
|
when :agent then last_agent_message_at
|
|
1378
1636
|
end
|
|
1379
|
-
|
|
1380
|
-
|
|
1637
|
+
return false if clock.blank?
|
|
1638
|
+
return true if message.created_at < clock
|
|
1639
|
+
return false unless message.created_at == clock
|
|
1640
|
+
|
|
1641
|
+
# Equal timestamps are NOT the same message. A coarse column, a frozen
|
|
1642
|
+
# clock in a test, or two very fast inserts can put two different
|
|
1643
|
+
# messages on one instant, and a `<=` here folded the second one in
|
|
1644
|
+
# without moving anything — so the assistant answered a question the
|
|
1645
|
+
# case had never registered. The only equal-timestamp message that
|
|
1646
|
+
# counts as folded in is the one the clock was set FROM.
|
|
1647
|
+
case role
|
|
1648
|
+
when :requester then last_requester_message_id.present? &&
|
|
1649
|
+
last_requester_message_id.to_s == message.id.to_s
|
|
1650
|
+
else false
|
|
1651
|
+
end
|
|
1381
1652
|
end
|
|
1382
1653
|
|
|
1383
1654
|
def opening_message?
|
|
@@ -1432,14 +1703,26 @@ module SupportDesk
|
|
|
1432
1703
|
# disagree on a live ticket. A closed ticket keeps its assignee as the
|
|
1433
1704
|
# record of who dealt with it, with no open row — that pair is the one
|
|
1434
1705
|
# shape `doctor` expects to see.
|
|
1435
|
-
def restore_assignment!(by:)
|
|
1436
|
-
return if unassigned?
|
|
1706
|
+
def restore_assignment!(by:, closed_by_assistant: false)
|
|
1707
|
+
return false if unassigned?
|
|
1708
|
+
|
|
1709
|
+
# A case the assistant closed and the customer reopened is a case she
|
|
1710
|
+
# got wrong (12 #21). It comes back to PEOPLE — unassigned, and capped
|
|
1711
|
+
# at :draft on this case for good, whatever her level is elsewhere. An
|
|
1712
|
+
# explicit hand-back is the only thing that lifts it, and the cap can
|
|
1713
|
+
# only tighten: an existing :observe stays :observe.
|
|
1714
|
+
if closed_by_assistant
|
|
1715
|
+
cap = [ assistant_cap&.to_sym, :draft ].compact.min_by { |level| AssistantPolicy::RANK.fetch(level) }
|
|
1716
|
+
update!(assignee: nil, assistant_cap: cap.to_s)
|
|
1717
|
+
return true
|
|
1718
|
+
end
|
|
1437
1719
|
|
|
1438
1720
|
if assignee.respond_to?(:support_agent?) && assignee.support_agent?
|
|
1439
1721
|
Assignment.open!(ticket: self, agent: assignee, by: by, reason: :reopened)
|
|
1440
1722
|
else
|
|
1441
1723
|
update!(assignee: nil)
|
|
1442
1724
|
end
|
|
1725
|
+
false
|
|
1443
1726
|
end
|
|
1444
1727
|
|
|
1445
1728
|
def awaiting_from_clocks
|
|
@@ -1464,6 +1747,10 @@ module SupportDesk
|
|
|
1464
1747
|
|
|
1465
1748
|
case role
|
|
1466
1749
|
when :requester
|
|
1750
|
+
# The opening message included: a case that starts with a question
|
|
1751
|
+
# is a case with something to answer, and the harness should hear
|
|
1752
|
+
# about it the same way it hears about every later message.
|
|
1753
|
+
emit_assistant_turn(message)
|
|
1467
1754
|
SupportDesk.emit_after_commit(:requester_replied, self, message) unless opening
|
|
1468
1755
|
when :agent
|
|
1469
1756
|
# The desk's OWN first word announces nothing: it is the start of a
|
|
@@ -1497,11 +1784,21 @@ module SupportDesk
|
|
|
1497
1784
|
conversation.messages.visible.oldest_first.map do |message|
|
|
1498
1785
|
{
|
|
1499
1786
|
at: message.created_at,
|
|
1500
|
-
|
|
1787
|
+
# "assistant" in every disclosure mode, silent ones included: what
|
|
1788
|
+
# a customer was told is a product decision, what an export says a
|
|
1789
|
+
# machine wrote is not.
|
|
1790
|
+
from: export_from(message),
|
|
1501
1791
|
body: message.visible_body,
|
|
1502
1792
|
attachments: message.try(:files)&.map { |file| file.try(:filename).to_s } || []
|
|
1503
1793
|
}
|
|
1504
1794
|
end
|
|
1505
1795
|
end
|
|
1796
|
+
|
|
1797
|
+
def export_from(message)
|
|
1798
|
+
return "you" if role_of(message) == :requester
|
|
1799
|
+
return "assistant" if assistant_message?(message)
|
|
1800
|
+
|
|
1801
|
+
"support"
|
|
1802
|
+
end
|
|
1506
1803
|
end
|
|
1507
1804
|
end
|