Skip to content

Reading Along: SupportChat

Guide Home | Previous: Reading Along: TicTacToe | Next: Reading Along: DeliveryDispatch

What you get from this chapter

You can open the SupportChat sample in an editor and follow a message from the customer's request to open a conversation, through agent assignment, to the conversation closing, through the code of each server it passes. The code in this chapter comes from the SupportChat sample in the per-language example repositories.

Picking a Sample introduces this sample. This chapter explains its roles and code locations, main message flows, and run verification in source order. See the SupportChat scenario for requirements and message contracts.

1. What This Sample Demonstrates

The customer and the agent each keep a single STREAM connection to the Session server. API handles authentication, and the conversation state is owned by a conversation Spot on Support. One agent handles several conversations at once, so the agent's single connection has a roster Actor and one conversation Actor per conversation bound to it. The agent sends through each room's Actor handle, and Session selects the relay target from the packet's Actor slot.

↗ View larger

The flow this chapter follows is authentication and the identity Actor binding → opening a conversation and assignment → the agent joining the conversation → chat relayed by Actor slot → the idle timer and closing.

2. Roles and Where the Code Lives

Role Processes Owns Code
Session 1 STREAM connections, authentication and join packets, Actor binding and Actor slot relay Server/Session
API 1 Token verification, the request to create a conversation Spot Server/Api
Support 1 Entry Spot, conversation Spot, identity, roster and conversation Actors, assignment and pushes Server/Support/Infrastructure
Client 1 The customer and agent scenarios, with self-checks Client

Participants, MessageSeq, typing and the idle/close transitions live in Server/Support/Domain and reference no framework type. The agent capacity decision belongs to Server/Support/Application. Messages are set by the JSON contract in Shared.

3. Server Configuration

Session calls objects in the Support mesh, calls the API channel, and receives STREAM connections.

Server/Session/main.cpp

options.add_client_server_channel ("supportchat.support").client ();
options.add_client_server_channel ("supportchat.api").client ();
auto support_spot = options.add_route_mesh (sample_names_t::mesh);
support_spot.set_routing_id (zlink::routing_id_t::from ("supportchat-session"));
support_spot.listen (topology.session_spot_router_endpoint);
support_spot.objects ().client ();
options.add_stream_node ("supportchat-session-stream")
  .bind (topology.session_stream_endpoint)
  .register_session<supportchat_session_t> ();

Support registers the Entry Spot, the Actor factory and the conversation Spot factory.

Server/Support/main.cpp

auto support_spot = options.add_route_mesh (sample_names_t::mesh);
support_spot.set_routing_id (
  zlink::routing_id_t::from (sample_names_t::support_node_routing_id));
support_spot.listen (topology.support_spot_router_endpoint);
support_spot.objects ()
  .server ()
  .add_entry_spot<support_entry_spot_t, supportchat_conversation_runtime_t, channel_client_t> ()
  .add_spot_factory<conversation_spot_t, supportchat_conversation_runtime_t> (
    sample_names_t::conversation_spot)
  .disable_relocation ()
  .add_actor_factory<support_user_actor_t, support_user_actor_factory_t> (
    support_user_actor_type)
  .preserve_state_with<support_user_actor_relocation_adapter_t> ();

4. Authentication and the Identity Actor

The first packet the client sends is an authentication request. The session splits packets by name into authentication, conversation join and everything else.

↗ View larger

Server/Session/main.cpp

task_t<void> on_packet (stream_t &stream,
                        const session_message_context_t &dispatch,
                        const zlink::message_t &payload) override
{
    if (dispatch.packet_name == authenticate_req_t::packet_name) {
        /* 인증은 API 서버가 소유한다(공통 sample spec §11). Session은 access token을
         * 그대로 넘기고 사용자 프로필을 만들어 내지 않는다. */
        auto verified = co_await _channels
                          .request ("supportchat.api",
                                    authenticate_user_req_t{
                                      payload.parse_json<authenticate_req_t> ().access_token})
                          .async<authenticate_user_res_t> ();
        if (!verified.accepted) {
            throw framework_exception_t (framework_error_kind_t::rejected,
                                         verified.reason.value_or ("AuthenticationRejected"));
        }
        const authenticate_res_t authenticated{
          *verified.actor_id, *verified.display_name, *verified.role};
        auto ensure = ensure_support_user_actor_req_t{authenticated.actor_id,
                                                      authenticated.display_name,
                                                      authenticated.role,
                                                      authenticated.actor_id};
        auto ensured = co_await _channels.request ("supportchat.support", ensure)
                         .async<ensure_support_user_actor_res_t> ();
        auto actor_ref = ensured.actor.to_actor_ref (sample_names_t::mesh);
        auto bound = co_await _actors.bind_or_get (actor_ref).async ();
        _identity_actor_id = std::string (bound.actor_id ());
        _identity_display_name = authenticated.display_name;
        _identity_role = authenticated.role;
        stream.reply_packet (zlink::message_t::from_json (authenticated)).async ();
        co_return;
    }
    if (dispatch.packet_name == join_conversation_req_t::packet_name) {
        const auto request = payload.parse_json<join_conversation_req_t> ();
        if (request.conversation_id.empty ()) {
            throw framework_exception_t (framework_error_kind_t::protocol_error,
                                         "JoinConversationReq is missing conversationId");
        }
        auto actor = require_actor (_identity_actor_id, std::string (dispatch.packet_name));
        if (_identity_role == role_t::agent) {
            actor = co_await ensure_agent_conversation_actor (request.conversation_id);
        }
        auto reply = co_await actor.relay_request (std::string (dispatch.packet_name), payload)
                       .async ();
        auto joined = reply.parse_json<join_conversation_res_t> ();
        joined.actor_id = std::string (actor.actor_id ());
        stream.reply_packet (zlink::message_t::from_json (joined)).async ();
        co_return;
    }
    auto actor = dispatch.actor
                   ? *dispatch.actor
                   : require_actor (_identity_actor_id, std::string (dispatch.packet_name));
    if (dispatch.can_reply) {
        auto reply = co_await actor.relay_request (payload).async ();
        stream.reply_packet (reply).async ();
        co_return;
    }
    co_await actor.relay (payload);
}

After authentication the session creates or finds the identity Actor and binds it to the current session. For a customer that Actor is the conversation participant itself; for an agent it is the roster Actor.

Server/Session/main.cpp

auto ensure = ensure_support_user_actor_req_t{authenticated.actor_id,
                                              authenticated.display_name,
                                              authenticated.role,
                                              authenticated.actor_id};
auto ensured = co_await _channels.request ("supportchat.support", ensure)
                 .async<ensure_support_user_actor_res_t> ();
auto actor_ref = ensured.actor.to_actor_ref (sample_names_t::mesh);
auto bound = co_await _actors.bind_or_get (actor_ref).async ();
_identity_actor_id = std::string (bound.actor_id ());
_identity_display_name = authenticated.display_name;
_identity_role = authenticated.role;

Reconnecting with the same id binds a new session to the same Actor. Binding is covered by Session and Actor.

5. Opening a Conversation and Assigning an Agent

The customer's open request is relayed to the identity Actor and arrives at the Entry Spot's actor request handler. The handler asks API to create the conversation and reserves a join into the conversation Spot with the id it receives.

Server/Support/main.cpp

task_t<open_conversation_res_t> open_conversation (support_user_actor_t &actor,
                                                   message_context_t &,
                                                   const open_conversation_req_t &request)
{
    if (actor.role != role_t::customer) {
        throw framework_exception_t (framework_error_kind_t::rejected,
                                     "only customer actors can open conversations");
    }
    auto allocated = co_await _channels
                       .request ("supportchat.api",
                                 open_conversation_api_req_t{
                                   actor.actor_id, actor.display_name, request.subject})
                       .async<open_conversation_api_res_t> ();
    const auto conversation_id = allocated.state.conversation_id;
    auto scheduled = actor.schedule_conversation_join (conversation_id, false);
    co_return open_conversation_res_t{conversation_id, std::move (scheduled.state)};
}

API creates the conversation Spot through the Spot manager. The framework issues the id and picks the owner.

Server/Api/main.cpp

auto created = co_await _spots.create (sample_names_t::conversation_spot)
                 .in_mesh (sample_names_t::mesh)
                 .creation_request (conversation_create_req_t{
                   request.customer_actor_id,
                   request.customer_display_name,
                   request.subject,
                   std::chrono::duration_cast<std::chrono::milliseconds> (
                     std::chrono::system_clock::now ().time_since_epoch ())
                     .count ()})
                 .async ();

Once the customer Actor's join completes, the conversation Spot picks an agent with capacity left and sends an assignment notify to that roster Actor's bound session. If no agent is left, the conversation waits rather than failing.

Server/Support/main.cpp

auto projected = require_conversation ();
conversation_state_t admission_state;
if (request.role == role_t::agent) {
    admission_state = projected.join_agent (request.participant_id, request.display_name)
                        .state;
} else {
    const auto joined = projected.join_customer (request.participant_id,
                                                 request.display_name);
    admission_state = joined.state;
    if (auto assigned = _runtime.assign_agent (joined.state.conversation_id)) {
        _pending_agent_assignments[std::string (actor_id)] = *assigned;
        admission_state = projected.assign_agent (*assigned).state;
    }
}
_pending_actor_joins.insert (std::string (actor_id));
co_return spot_actor_join_result_t::accept (
  join_conversation_res_t{true, std::string (actor_id), admission_state});

The roster Actor lives in the Entry Spot and is not a member of the conversation Spot. Sending to its bound session still needs no knowledge of where the Actor is.

Server/Support/main.cpp

const auto pending = _pending_agent_assignments.find (actor.actor_id);
if (pending != _pending_agent_assignments.end ()) {
    const auto assigned = pending->second;
    _pending_agent_assignments.erase (pending);
    auto assignment = require_conversation ().assign_agent (assigned);
    if (auto roster = _runtime.actor_for (assigned)) {
        co_await (*roster)
          ->context ()
          .bound_session ()
          .send (conversation_assigned_notify_t{assignment.state.conversation_id,
                                                assignment.state})
          .async ();
    }
    co_return join_conversation_res_t{false, actor.actor_id, assignment.state};
}

Creating a User Spot is covered by Spot, and reserving a join by Actor Membership.

6. The Agent Joining a Conversation — One Connection, Several Actors

An Actor belongs to one Spot at a time. The roster Actor has to stay in the Entry Spot, so when the agent enters a conversation the session creates a new per-conversation Actor and binds it to the same session as well.

Server/Session/main.cpp

auto ensured = co_await _channels
                 .request ("supportchat.support",
                           ensure_agent_conversation_req_t{
                             _identity_actor_id, _identity_display_name, conversation_id})
                 .async<ensure_agent_conversation_res_t> ();
auto actor_ref = ensured.actor.to_actor_ref (sample_names_t::mesh);
co_return co_await _actors.bind_or_get (actor_ref).async ();

From then on the pushes of that conversation arrive at the conversation Actor's bound session — the agent's same connection. How many may be bound and the rules are covered by How Session Binding Works.

7. Chat — Actor-Based Relay

The agent finds a room's Actor handle using JoinConversationRes.actorId and sends chat packets through that handle. The customer sends through the single Actor bound to its connection. Authentication and JoinConversationReq(conversationId) are binding packets handled directly by the Session handler. Session relays the other packets without decoding their payloads. If a packet has an Actor slot, Session relays it to that Actor bound in the dispatch context; otherwise, it uses the connection's identity Actor.

↗ View larger

Server/Session/main.cpp

auto actor = dispatch.actor
               ? *dispatch.actor
               : require_actor (_identity_actor_id, std::string (dispatch.packet_name));

The conversation Spot assigns a MessageSeq to the message and pushes a notify to the bound session of every participant except the sender.

Server/Support/main.cpp

task_t<send_chat_message_res_t> send_message (support_user_actor_t &actor,
                                              message_context_t &,
                                              const send_chat_message_req_t &request)
{
    const auto previous = require_conversation ().snapshot ();
    auto sent = require_conversation ().send_message (
      actor.participant_id, request.text, now_unix_ms ());
    if (sent.state.status != previous.status) {
        report_status (sent.state);
    }
    co_await broadcast_except (
      actor.participant_id,
      chat_message_notify_t{sent.state.conversation_id, sent.message, sent.state},
      chat_message_notify_t::packet_name);
    co_return sent;
}

The lifetime of a reply token is covered by How STREAM Works, and the one-at-a-time execution boundary inside a Spot by The Execution Model.

8. The Idle Timer and Closing

Some time after the last message, the conversation Spot's timer moves the conversation to idle, and if no message arrives within the grace period it closes it.

↗ View larger

Server/Support/main.cpp

task_t<void> on_initialize () override
{
    _idle_timer = _context.add_timer<conversation_idle_timer_handler_t> (
      "conversation-idle", std::chrono::milliseconds (500));
    co_return;
}

task_t<void> on_idle_tick ()
{
    if (!_conversation) {
        co_return;
    }
    auto transition = _conversation->advance_time (now_unix_ms ());
    if (const auto *idle = std::get_if<conversation_idle_notify_t> (&transition)) {
        report_status (idle->state);
        co_await broadcast (*idle, conversation_idle_notify_t::packet_name);
    }
    if (const auto *closed = std::get_if<conversation_closed_notify_t> (&transition)) {
        _runtime.release_conversation (closed->conversation_id);
        report_status (closed->state);
        co_await broadcast (*closed, conversation_closed_notify_t::packet_name);
    }
    co_return;
}

When a conversation closes, the assigned agent's capacity is returned. When the agent's connection drops, the Entry Spot's disconnect callback withdraws the availability, and the conversation state stays as it is and continues after reconnecting.

// The C++ sample has no disconnect callback on its Entry Spot; availability is not withdrawn on disconnect.

The agent turns availability on and off with a request.

Server/Support/main.cpp

set_agent_available_res_t set_available (support_user_actor_t &actor,
                                         message_context_t &,
                                         const set_agent_available_req_t &request)
{
    if (actor.role != role_t::agent) {
        throw framework_exception_t (framework_error_kind_t::rejected,
                                     "only agent actors can set availability");
    }
    return _runtime.set_agent_available (
      actor.actor_id, actor.display_name, request.is_available);
}

Timers are covered by Timers and Workers, and where the disconnect notification lands by How Session Binding Works.

9. Running and Verifying

One runner starts the Redis container, the server processes and the client scenario together.

framework/languages/cpp/samples/SupportChat/run_sample.sh

The client asserts the assignment notify, the MessageSeq order, the typing notify, the idle and close transitions and the state after reconnecting. The checks and the exact log strings are set by the SupportChat scenario.