/**
* Copyright 2018 The Nakama Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
namespace Nakama
{
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Net.WebSockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using TinyJson;
///
/// A socket which uses the WebSocket protocol to interact with Nakama server.
///
internal class WebSocketWrapper : WebSocketEventListener, ISocket
{
///
public event EventHandler OnChannelMessage;
///
public event EventHandler OnChannelPresence;
///
public event EventHandler OnConnect;
///
public event EventHandler OnDisconnect;
///
public event EventHandler OnError;
///
public event EventHandler OnMatchmakerMatched;
///
public event EventHandler OnMatchState;
///
public event EventHandler OnMatchPresence;
///
public event EventHandler OnNotification;
///
public event EventHandler OnStatusPresence;
///
public event EventHandler OnStreamPresence;
///
public event EventHandler OnStreamState;
private static readonly IReadOnlyList NoPresences = new List(0);
private readonly Uri _baseUri;
private readonly WebSocketOptions _options;
private readonly ConcurrentDictionary> _messageReplies;
private bool IsTrace => _options.Logger != null;
internal WebSocketWrapper(Uri baseUri, ILogger logger, int timeout) : this(baseUri, new WebSocketOptions
{
Logger = logger,
ConnectTimeout = TimeSpan.FromMilliseconds(timeout)
})
{
}
internal WebSocketWrapper(Uri baseUri, WebSocketOptions options) : base(options)
{
_baseUri = baseUri;
_messageReplies = new ConcurrentDictionary>();
options.ValidateOptions();
_options = options.Clone();
if (!IsTrace) _options.Logger = new NullLogger();
OnError = (sender, exception) => _options.Logger.Error(exception);
OnChannelMessage = (sender, message) =>
_options.Logger.DebugFormat("Received channel message '{0}'", message);
OnChannelPresence = (sender, _event) =>
_options.Logger.DebugFormat("Received channel presence '{0}'", _event);
OnConnect = (sender, args) => _options.Logger.Debug("Socket connected.");
OnDisconnect = (sender, args) => _options.Logger.Debug("Socket disconnected.");
OnMatchmakerMatched = (sender, matched) =>
_options.Logger.DebugFormat("Received matchmaker match '{0}'", matched);
OnMatchPresence = (sender, _event) =>
_options.Logger.DebugFormat("Received match presence '{0}'", _event);
OnMatchState = (sender, state) => _options.Logger.DebugFormat("Received match state '{0}'", state);
OnNotification = (sender, notification) =>
_options.Logger.DebugFormat("Received notification '{0}'", notification);
OnStatusPresence = (sender, _event) =>
_options.Logger.DebugFormat("Received status presence '{0}'", _event);
OnStreamPresence = (sender, _event) =>
_options.Logger.DebugFormat("Received stream presence '{0}'", _event);
OnStreamState = (sender, state) => _options.Logger.DebugFormat("Received stream state '{0}'", state);
Connected += (sender, args) => OnConnect.Invoke(this, EventArgs.Empty);
Disconnected += (sender, args) => OnDisconnect.Invoke(this, EventArgs.Empty);
ErrorReceived += (sender, exception) => OnError?.Invoke(this, exception);
MessageReceived += (sender, message) =>
{
if (IsTrace)
{
_options.Logger.DebugFormat("Socket read message: '{0}'", message);
}
var envelope = message.FromJson();
if (!string.IsNullOrEmpty(envelope.Cid))
{
// Handle message response.
TaskCompletionSource completer;
var cid = envelope.Cid;
_messageReplies.TryRemove(cid, out completer);
if (completer == null)
{
if (IsTrace) _options.Logger.InfoFormat("No task completer for message: '{0}'", cid);
return;
}
if (envelope.Error != null)
{
// FIXME use a dedicated exception type.
completer.SetException(new WebSocketException(envelope.Error.Message));
}
else
{
completer.SetResult(envelope);
}
}
else if (envelope.Error != null)
{
OnError?.Invoke(this, new WebSocketException(envelope.Error.Message));
}
else if (envelope.ChannelMessage != null)
{
OnChannelMessage?.Invoke(this, envelope.ChannelMessage);
}
else if (envelope.ChannelPresenceEvent != null)
{
OnChannelPresence?.Invoke(this, envelope.ChannelPresenceEvent);
}
else if (envelope.MatchmakerMatched != null)
{
OnMatchmakerMatched?.Invoke(this, envelope.MatchmakerMatched);
}
else if (envelope.MatchPresenceEvent != null)
{
OnMatchPresence?.Invoke(this, envelope.MatchPresenceEvent);
}
else if (envelope.MatchState != null)
{
OnMatchState?.Invoke(this, envelope.MatchState);
}
else if (envelope.NotificationList != null)
{
foreach (var notification in envelope.NotificationList.Notifications)
{
OnNotification?.Invoke(this, notification);
}
}
else if (envelope.StatusPresenceEvent != null)
{
OnStatusPresence?.Invoke(this, envelope.StatusPresenceEvent);
}
else if (envelope.StreamPresenceEvent != null)
{
OnStreamPresence?.Invoke(this, envelope.StreamPresenceEvent);
}
else if (envelope.StreamState != null)
{
OnStreamState?.Invoke(this, envelope.StreamState);
}
else
{
if (IsTrace)
{
_options.Logger.InfoFormat("Socket received unrecognised message: '{0}'", message);
}
}
};
}
///
public async Task AddMatchmakerAsync(string query = "*", int minCount = 2, int maxCount = 8,
Dictionary stringProperties = null, Dictionary numericProperties = null)
{
var envelope = new WebSocketMessageEnvelope
{
Cid = Guid.NewGuid().ToString(),
MatchmakerAdd = new MatchmakerAddMessage
{
MaxCount = maxCount,
MinCount = minCount,
NumericProperties = numericProperties ?? new Dictionary(),
StringProperties = stringProperties ?? new Dictionary(),
Query = query
}
};
var response = await SendAsync(envelope).ConfigureAwait(false);
return response.MatchmakerTicket;
}
///
public async Task ConnectAsync(ISession session, CancellationToken ct = default(CancellationToken),
bool appearOnline = false, int connectTimeout = 5000)
{
var addr = new UriBuilder(_baseUri)
{
Path = "/ws",
Query = string.Concat("lang=en&status=", appearOnline, "&token=", session.AuthToken)
};
await base.ConnectAsync(addr.Uri, ct);
}
///
public async Task DisconnectAsync(bool dispatch = true)
{
await CloseAsync();
if (dispatch)
{
OnDisconnect?.Invoke(this, EventArgs.Empty);
}
_messageReplies.Clear();
}
public new void Dispose()
{
base.Dispose();
_messageReplies.Clear();
OnDisconnect?.Invoke(this, EventArgs.Empty);
}
///
public async Task CreateMatchAsync()
{
var envelope = new WebSocketMessageEnvelope
{
Cid = Guid.NewGuid().ToString(),
MatchCreate = new MatchCreateMessage()
};
var response = await SendAsync(envelope).ConfigureAwait(false);
return response.Match;
}
///
public async Task JoinChatAsync(string target, ChannelType type, bool persistence = false,
bool hidden = false)
{
var envelope = new WebSocketMessageEnvelope
{
Cid = Guid.NewGuid().ToString(),
ChannelJoin = new ChannelJoinMessage
{
Hidden = hidden,
Persistence = persistence,
Target = target,
Type = (int) type
}
};
var response = await SendAsync(envelope).ConfigureAwait(false);
return response.Channel;
}
///
public async Task FollowUsersAsync(IEnumerable userIds)
{
var envelope = new WebSocketMessageEnvelope
{
Cid = Guid.NewGuid().ToString(),
StatusFollow = new StatusFollowMessage
{
UserIds = new List(userIds)
}
};
var response = await SendAsync(envelope);
return response.Status;
}
///
public async Task JoinMatchAsync(IMatchmakerMatched matched)
{
var message = new MatchJoinMessage();
if (matched.Token != null)
{
message.Token = matched.Token;
}
else
{
message.MatchId = matched.MatchId;
}
var envelope = new WebSocketMessageEnvelope
{
Cid = Guid.NewGuid().ToString(),
MatchJoin = message
};
var response = await SendAsync(envelope).ConfigureAwait(false);
return response.Match;
}
///
public async Task JoinMatchAsync(string matchId)
{
var envelope = new WebSocketMessageEnvelope
{
Cid = Guid.NewGuid().ToString(),
MatchJoin = new MatchJoinMessage
{
MatchId = matchId
}
};
var response = await SendAsync(envelope).ConfigureAwait(false);
return response.Match;
}
///
public async Task LeaveChatAsync(IChannel channel) => await LeaveChatAsync(channel.Id);
///
public async Task LeaveChatAsync(string channelId)
{
var envelope = new WebSocketMessageEnvelope
{
ChannelLeave = new ChannelLeaveMessage
{
ChannelId = channelId
}
};
await SendAsync(envelope).ConfigureAwait(false);
}
///
public async Task LeaveMatchAsync(IMatch match) => await LeaveMatchAsync(match.Id);
///
public async Task LeaveMatchAsync(string matchId)
{
var envelope = new WebSocketMessageEnvelope
{
Cid = Guid.NewGuid().ToString(),
MatchLeave = new MatchLeaveMessage
{
MatchId = matchId
}
};
await SendAsync(envelope).ConfigureAwait(false);
}
///
public async Task RemoveChatMessageAsync(IChannel channel, string messageId) =>
await RemoveChatMessageAsync(channel.Id, messageId);
///
public async Task RemoveChatMessageAsync(string channelId, string messageId)
{
var envelope = new WebSocketMessageEnvelope
{
Cid = Guid.NewGuid().ToString(),
ChannelMessageRemove = new ChannelRemoveMessage
{
ChannelId = channelId,
MessageId = messageId
}
};
var response = await SendAsync(envelope).ConfigureAwait(false);
return response.ChannelMessageAck;
}
///
public async Task RemoveMatchmakerAsync(IMatchmakerTicket matchmakerTicket) =>
await RemoveMatchmakerAsync(matchmakerTicket.Ticket);
///
public async Task RemoveMatchmakerAsync(string ticket)
{
var envelope = new WebSocketMessageEnvelope
{
Cid = Guid.NewGuid().ToString(),
MatchmakerRemove = new MatchmakerRemoveMessage
{
Ticket = ticket
}
};
await SendAsync(envelope).ConfigureAwait(false);
}
///
public async Task RpcAsync(string id, string content)
{
var envelope = new WebSocketMessageEnvelope
{
Cid = Guid.NewGuid().ToString(),
Rpc = new ApiRpc
{
Id = id,
Payload = content
}
};
var response = await SendAsync(envelope).ConfigureAwait(false);
return response.Rpc;
}
///
public async Task SendMatchStateAsync(string matchId, long opCode, string state,
IEnumerable presences = null) =>
await SendMatchStateAsync(matchId, opCode, Encoding.UTF8.GetBytes(state), presences);
///
public async Task SendMatchStateAsync(string matchId, long opCode, byte[] state,
IEnumerable presences = null)
{
var envelope = new WebSocketMessageEnvelope
{
MatchStateSend = new MatchSendMessage
{
MatchId = matchId,
OpCode = Convert.ToString(opCode),
Presences = BuildPresenceList(presences),
State = Convert.ToBase64String(state)
}
};
await SendAsync(envelope).ConfigureAwait(false);
}
///
public void SendMatchState(string matchId, long opCode, string state,
IEnumerable presences = null) =>
SendMatchState(matchId, opCode, Encoding.UTF8.GetBytes(state), presences);
///
public async void SendMatchState(string matchId, long opCode, byte[] state,
IEnumerable presences = null)
{
var envelope = new WebSocketMessageEnvelope
{
MatchStateSend = new MatchSendMessage
{
MatchId = matchId,
OpCode = Convert.ToString(opCode),
Presences = BuildPresenceList(presences),
State = Convert.ToBase64String(state)
}
};
try
{
await SendAsync(envelope).ConfigureAwait(false);
}
catch (Exception e)
{
OnError?.Invoke(this, e);
}
}
///
public async Task UnfollowUsersAsync(IEnumerable userIds)
{
var envelope = new WebSocketMessageEnvelope
{
Cid = Guid.NewGuid().ToString(),
StatusUnfollow = new StatusUnfollowMessage
{
UserIds = new List(userIds)
}
};
await SendAsync(envelope);
}
///
public async Task
UpdateChatMessageAsync(IChannel channel, string messageId, string content) =>
await UpdateChatMessageAsync(channel.Id, messageId, content);
///
public async Task UpdateChatMessageAsync(string channelId, string messageId, string content)
{
var envelope = new WebSocketMessageEnvelope
{
Cid = Guid.NewGuid().ToString(),
ChannelMessageUpdate = new ChannelUpdateMessage
{
ChannelId = channelId,
MessageId = messageId,
Content = content
}
};
var response = await SendAsync(envelope).ConfigureAwait(false);
return response.ChannelMessageAck;
}
///
public async Task UpdateStatusAsync(string status)
{
var envelope = new WebSocketMessageEnvelope
{
Cid = Guid.NewGuid().ToString(),
StatusUpdate = new StatusUpdateMessage
{
Status = status
}
};
await SendAsync(envelope);
}
///
public async Task WriteChatMessageAsync(IChannel channel, string content) =>
await WriteChatMessageAsync(channel.Id, content);
///
public async Task WriteChatMessageAsync(string channelId, string content)
{
var envelope = new WebSocketMessageEnvelope
{
Cid = Guid.NewGuid().ToString(),
ChannelMessageSend = new ChannelSendMessage
{
ChannelId = channelId,
Content = content
}
};
var response = await SendAsync(envelope).ConfigureAwait(false);
return response.ChannelMessageAck;
}
private async Task SendAsync(WebSocketMessageEnvelope message)
{
if (!IsConnected)
{
throw new InvalidOperationException("Socket is not connected.");
}
Send(message.ToJson());
if (string.IsNullOrEmpty(message.Cid))
{
// No response required.
return null;
}
var completer = new TaskCompletionSource();
_messageReplies[message.Cid] = completer;
var resultTask = completer.Task;
var timeoutTask = Task.Delay(_options.ConnectTimeout);
if (await Task.WhenAny(resultTask, timeoutTask).ConfigureAwait(false) == timeoutTask)
{
throw new TimeoutException($"Socket send timed out after {_options.ConnectTimeout.ToString()} time.");
}
return await resultTask.ConfigureAwait(false);
}
private static List BuildPresenceList(IEnumerable presences)
{
if(presences == null)
{
return (List) NoPresences;
}
List presenceList = presences as List;
if (presenceList != null)
{
return presenceList;
}
presenceList = new List();
foreach(UserPresence concretePresence in presences)
{
presenceList.Add(concretePresence);
}
return presenceList;
}
}
}