Reading Along: SupportChat¶
Guide Home | Previous: Reading Along: TicTacToe | Next: Reading Along: DeliveryDispatch
View in another language — C++ · C#/.NET · Java · Kotlin · Node/TypeScript
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.
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/SessionServerHostFactory.cs
var mesh = options
.AddRouteMesh(SampleNames.MeshName)
.Listen(session.MeshEndpoint)
.SetRoutingIdPrefix("support-session");
mesh.Objects().Client();
options.AddClientServerChannel(SampleNames.ApiChannel).Client();
options
.AddStreamNode(SampleNames.StreamNode)
.Bind(session.StreamEndpoint)
.EnableActorDispatch()
.AddSession<SupportChatSession>();
Support registers the Entry Spot, the Actor factory and the conversation Spot factory.
Server/Support/SupportServerHostFactory.cs
options.AddHandlersFromAssemblyOf(typeof(SupportServerHostFactory));
var mesh = options
.AddRouteMesh(SampleNames.MeshName)
.Listen(topology.MeshEndpoint)
.SetRoutingIdPrefix("support-owner");
mesh.Objects()
.Server()
.AddEntrySpot<SupportEntrySpot>()
.AddActorFactory<SupportUserActor, SupportUserActorFactory>(
SampleNames.SupportActorType,
factory => factory.DisableRelocation()
)
.AddSpotFactory<ConversationSpot>(
SampleNames.ConversationSpotType,
factory => factory.DisableRelocation()
);
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.
Server/Session/Sessions/SupportChatSession.cs
public async ValueTask OnDispatchAsync(
ZLinkSessionDispatchContext dispatch,
ZLinkMessage payload,
CancellationToken cancellationToken
)
{
switch (dispatch.PacketName)
{
case nameof(AuthenticateReq):
await AuthenticateAsync(payload, cancellationToken);
return;
case nameof(JoinConversationReq):
await JoinConversationAsync(payload, cancellationToken);
return;
default:
await RelayConversationPacketAsync(dispatch, payload, cancellationToken);
return;
}
}
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/Sessions/SupportChatSession.cs
// The identity actor's ParticipantId is its own ActorId (customer id or roster id).
var actor = await GetOrCreateActorAsync(
authenticated.ActorId,
new SupportUserActorCreateReq(
authenticated.ActorId,
authenticated.DisplayName,
authenticated.Role,
authenticated.ActorId
),
cancellationToken
);
_identityActor = await Context.Actors.BindOrGetAsync(actor, cancellationToken);
_identityActorId = authenticated.ActorId;
_identityDisplayName = authenticated.DisplayName;
_identityRole = 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/Infrastructure/ZLink/Spots/EntrySpot/Handlers/OpenConversationActorHandler.cs
var opened = await entrySpot
.Context.Outbound.RequestToChannel(
SampleNames.ApiChannel,
new OpenConversationApiReq(actor.ParticipantId, actor.DisplayName, message.Subject)
)
.Async<OpenConversationApiRes>(cancellationToken);
actor.TrackDeferredJoin(opened.State.ConversationId, notifyBoundSession: false);
actor
.Context.JoinSpot(
opened.State.ConversationId,
new JoinConversationReq(
opened.State.ConversationId,
actor.ParticipantId,
actor.Role,
actor.DisplayName
)
)
.Defer();
API creates the conversation Spot through the Spot manager. The framework issues the id and picks the owner.
Server/Api/Handlers/OpenConversationHandler.cs
var created = await spots
.Create(SampleNames.ConversationSpotType)
.InMesh(SampleNames.MeshName)
.Request(
new ConversationCreateReq(
request.CustomerActorId,
request.CustomerDisplayName,
request.Subject,
DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
)
)
.Async(cancellationToken);
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/Infrastructure/ZLink/Spots/ConversationSpot/ConversationSpot.cs
private async ValueTask AssignAgentAsync(CancellationToken cancellationToken)
{
var conversation = RequireConversation();
var assigned = assignment.AssignForConversation(conversation.ConversationId);
if (assigned is null)
return;
await notifications.PublishAssignedToRosterAsync(
directory.Get(assigned.RosterActorId).Actor,
conversation.Snapshot(),
cancellationToken
);
logger.LogInformation(
"support conversation: assigned. conversation={ConversationId}, roster={RosterActorId}",
conversation.ConversationId,
assigned.RosterActorId
);
}
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/Infrastructure/ZLink/Spots/ConversationSpot/Notifications/ConversationNotificationPublisher.cs
public async ValueTask PublishAssignedToRosterAsync(
SupportUserActor roster,
ConversationSnapshot snapshot,
CancellationToken cancellationToken
)
{
var state = ConversationContracts.ToState(snapshot);
await roster
.Context.BoundSession.Send(new ConversationAssignedNotify(state.ConversationId, state))
.Async();
}
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/Sessions/SupportChatSession.cs
// An agent joins each conversation through its own per-conversation actor. Ask
// the Support server to create it and join it into the ConversationSpot, then
// bind it onto this session so the agent client receives that room's pushes.
var conversationActorId = $"{_identityActorId}@{join.ConversationId}";
var actor = await GetOrCreateActorAsync(
conversationActorId,
new SupportUserActorCreateReq(
conversationActorId,
_identityDisplayName,
SupportChatRoles.Agent,
_identityActorId
),
cancellationToken
);
var boundActor = await Context.Actors.BindOrGetAsync(actor, cancellationToken);
await boundActor.RelayAsync(payload);
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.
Server/Session/Sessions/SupportChatSession.cs
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/Infrastructure/ZLink/Spots/ConversationSpot/Notifications/ConversationNotificationPublisher.cs
private static async ValueTask PublishMessageAsync(
ConversationEvent conversationEvent,
ConversationState state,
IReadOnlyDictionary<string, SupportUserActor> actors,
CancellationToken cancellationToken
)
{
var message =
conversationEvent.Message
?? throw new InvalidOperationException("Message event requires a chat message.");
var chatMessage = ConversationContracts.ToMessage(message);
await PublishAllAsync(
Exclude(actors, message.SenderActorId),
async actor =>
{
await actor
.Context.BoundSession.Send(
new ChatMessageNotify(state.ConversationId, chatMessage, state)
)
.Async();
}
);
}
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.
Server/Support/Infrastructure/ZLink/Spots/ConversationSpot/Handlers/ConversationIdleTimerHandler.cs
[ZLinkSpotTimerHandler("conversation-idle", 200)]
internal sealed class ConversationIdleTimerHandler : IZLinkSpotTimerHandler<ConversationSpot>
{
public ValueTask HandleAsync(
ConversationSpot spot,
ZLinkTimerTick tick,
CancellationToken cancellationToken
)
{
_ = tick;
return spot.CheckIdleAsync(cancellationToken);
}
}
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.
Server/Support/Infrastructure/ZLink/Spots/EntrySpot/SupportEntrySpot.cs
public ValueTask OnDisconnectActorAsync(
SupportUserActor actor,
CancellationToken cancellationToken
)
{
if (string.Equals(actor.Role, SupportChatRoles.Agent, StringComparison.Ordinal))
{
assignment.SetAvailable(actor.ActorId, actor.DisplayName, false);
logger.LogInformation(
"support entry: agent disconnected, availability removed. actor={ActorId}",
actor.ActorId
);
}
return ValueTask.CompletedTask;
}
The agent turns availability on and off with a request.
Server/Support/Infrastructure/ZLink/Spots/EntrySpot/Handlers/SetAgentAvailableHandler.cs
public ValueTask<SetAgentAvailableRes> HandleAsync(
SupportEntrySpot entrySpot,
SupportUserActor actor,
IZLinkMessageContext context,
SetAgentAvailableReq message,
CancellationToken cancellationToken
)
{
if (!string.Equals(actor.Role, SupportChatRoles.Agent, StringComparison.Ordinal))
throw new InvalidOperationException("Only agent actors can set availability.");
actors.AddOrUpdate(actor);
assignment.SetAvailable(actor.ActorId, actor.DisplayName, message.IsAvailable);
return ValueTask.FromResult(new SetAgentAvailableRes(message.IsAvailable));
}
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.
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.
10. Related Documents¶
- Comparison with the other samples and how to choose: Picking a Sample
- Requirements, message contract and completion criteria: SupportChat scenario
- A layout that binds a single Actor per session: Reading Along: Bingo