// Copyright (c) 2007-2018 ppy Pty Ltd . // Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Configuration; using osu.Framework.Graphics; using osu.Framework.Logging; using osu.Framework.Threading; using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Users; namespace osu.Game.Online.Chat { /// /// Manages everything channel related /// public class ChannelManager : Component, IOnlineComponent { /// /// The channels the player joins on startup /// private readonly string[] defaultChannels = { @"#lazer", @"#osu", @"#lobby" }; /// /// The currently opened channel /// public Bindable CurrentChannel { get; } = new Bindable(); /// /// The Channels the player has joined /// public ObservableCollection JoinedChannels { get; } = new ObservableCollection(); //todo: should be publicly readonly /// /// The channels available for the player to join /// public ObservableCollection AvailableChannels { get; } = new ObservableCollection(); //todo: should be publicly readonly private IAPIProvider api; private ScheduledDelegate fetchMessagesScheduleder; public ChannelManager() { CurrentChannel.ValueChanged += currentChannelChanged; } /// /// Opens a channel or switches to the channel if already opened. /// /// If the name of the specifed channel was not found this exception will be thrown. /// public void OpenChannel(string name) { if (name == null) throw new ArgumentNullException(nameof(name)); CurrentChannel.Value = AvailableChannels.FirstOrDefault(c => c.Name == name) ?? throw new ChannelNotFoundException(name); } /// /// Opens a new private channel. /// /// The user the private channel is opened with. public void OpenPrivateChannel(User user) { if (user == null) throw new ArgumentNullException(nameof(user)); CurrentChannel.Value = JoinedChannels.FirstOrDefault(c => c.Type == ChannelType.PM && c.Users.Count == 1 && c.Users.Any(u => u.Id == user.Id)) ?? new Channel { Name = user.Username, Users = { user }, Type = ChannelType.PM }; } private void currentChannelChanged(Channel channel) => JoinChannel(channel); /// /// Ensure we run post actions in sequence, once at a time. /// private readonly Queue postQueue = new Queue(); /// /// Posts a message to the currently opened channel. /// /// The message text that is going to be posted /// Is true if the message is an action, e.g.: user is currently eating public void PostMessage(string text, bool isAction = false) { if (CurrentChannel.Value == null) return; var currentChannel = CurrentChannel.Value; void dequeueAndRun() { if (postQueue.Count > 0) postQueue.Dequeue().Invoke(); } postQueue.Enqueue(() => { if (!api.IsLoggedIn) { currentChannel.AddNewMessages(new ErrorMessage("Please sign in to participate in chat!")); return; } var message = new LocalEchoMessage { Sender = api.LocalUser.Value, Timestamp = DateTimeOffset.Now, ChannelId = CurrentChannel.Value.Id, IsAction = isAction, Content = text }; currentChannel.AddLocalEcho(message); // if this is a PM and the first message, we need to do a special request to create the PM channel if (currentChannel.Type == ChannelType.PM && !currentChannel.Joined) { var createNewPrivateMessageRequest = new CreateNewPrivateMessageRequest(currentChannel.Users.First(), message); createNewPrivateMessageRequest.Success += createRes => { currentChannel.Id = createRes.ChannelID; currentChannel.ReplaceMessage(message, createRes.Message); dequeueAndRun(); }; createNewPrivateMessageRequest.Failure += exception => { Logger.Error(exception, "Posting message failed."); currentChannel.ReplaceMessage(message, null); dequeueAndRun(); }; api.Queue(createNewPrivateMessageRequest); return; } var req = new PostMessageRequest(message); req.Success += m => { currentChannel.ReplaceMessage(message, m); dequeueAndRun(); }; req.Failure += exception => { Logger.Error(exception, "Posting message failed."); currentChannel.ReplaceMessage(message, null); dequeueAndRun(); }; api.Queue(req); }); // always run if the queue is empty if (postQueue.Count == 1) dequeueAndRun(); } /// /// Posts a command locally. Commands like /help will result in a help message written in the current channel. /// /// the text containing the command identifier and command parameters. public void PostCommand(string text) { if (CurrentChannel.Value == null) return; var parameters = text.Split(new[] { ' ' }, 2); string command = parameters[0]; string content = parameters.Length == 2 ? parameters[1] : string.Empty; switch (command) { case "me": if (string.IsNullOrWhiteSpace(content)) { CurrentChannel.Value.AddNewMessages(new ErrorMessage("Usage: /me [action]")); break; } PostMessage(content, true); break; case "help": CurrentChannel.Value.AddNewMessages(new InfoMessage("Supported commands: /help, /me [action]")); break; default: CurrentChannel.Value.AddNewMessages(new ErrorMessage($@"""/{command}"" is not supported! For a list of supported commands see /help")); break; } } private void handleChannelMessages(IEnumerable messages) { var channels = JoinedChannels.ToList(); foreach (var group in messages.GroupBy(m => m.ChannelId)) channels.Find(c => c.Id == group.Key)?.AddNewMessages(group.ToArray()); } private void initializeChannels() { var req = new ListChannelsRequest(); var joinDefaults = JoinedChannels.Count == 0; req.Success += channels => { foreach (var channel in channels) { var ch = getChannel(channel, addToAvailable: true); // join any channels classified as "defaults" if (joinDefaults && defaultChannels.Any(c => c.Equals(channel.Name, StringComparison.OrdinalIgnoreCase))) JoinChannel(ch); } }; req.Failure += error => { Logger.Error(error, "Fetching channel list failed"); initializeChannels(); }; api.Queue(req); } /// /// Fetches inital messages of a channel /// /// TODO: remove this when the API supports returning initial fetch messages for more than one channel by specifying the last message id per channel instead of one last message id globally. /// right now it caps out at 50 messages and therefore only returns one channel's worth of content. /// /// The channel private void fetchInitalMessages(Channel channel) { if (channel.Id <= 0) return; var fetchInitialMsgReq = new GetMessagesRequest(channel); fetchInitialMsgReq.Success += messages => { handleChannelMessages(messages); channel.MessagesLoaded = true; // this will mark the channel as having received messages even if there were none. }; api.Queue(fetchInitialMsgReq); } /// /// Find an existing channel instance for the provided channel. Lookup is performed basd on ID. /// The provided channel may be used if an existing instance is not found. /// /// A candidate channel to be used for lookup or permanently on lookup failure. /// Whether the channel should be added to if not already. /// Whether the channel should be added to if not already. /// The found channel. private Channel getChannel(Channel lookup, bool addToAvailable = false, bool addToJoined = false) { Channel found = null; bool lookupCondition(Channel ch) => lookup.Id > 0 ? ch.Id == lookup.Id : lookup.Name == ch.Name; var available = AvailableChannels.FirstOrDefault(lookupCondition); if (available != null) found = available; var joined = JoinedChannels.FirstOrDefault(lookupCondition); if (found == null && joined != null) found = joined; if (found == null) { found = lookup; // if we're using a channel object from the server, we want to remove ourselves from the users list. // this is because we check the first user in the channel to display a name/icon on tabs for now. var foundSelf = found.Users.FirstOrDefault(u => u.Id == api.LocalUser.Value.Id); if (foundSelf != null) found.Users.Remove(foundSelf); } if (joined == null && addToJoined) JoinedChannels.Add(found); if (available == null && addToAvailable) AvailableChannels.Add(found); return found; } /// /// Joins a channel if it has not already been joined. /// /// The channel to join. /// Whether the channel has already been joined server-side. Will skip a join request. /// The joined channel. Note that this may not match the parameter channel as it is a backed object. public Channel JoinChannel(Channel channel, bool alreadyJoined = false) { if (channel == null) return null; channel = getChannel(channel, addToJoined: true); // ensure we are joined to the channel if (!channel.Joined.Value) { if (alreadyJoined) channel.Joined.Value = true; else { switch (channel.Type) { case ChannelType.Public: var req = new JoinChannelRequest(channel, api.LocalUser); req.Success += () => JoinChannel(channel, true); req.Failure += ex => LeaveChannel(channel); api.Queue(req); return channel; } } } if (CurrentChannel.Value == null) CurrentChannel.Value = channel; if (!channel.MessagesLoaded) { // let's fetch a small number of messages to bring us up-to-date with the backlog. fetchInitalMessages(channel); } return channel; } public void LeaveChannel(Channel channel) { if (channel == null) return; if (channel == CurrentChannel.Value) CurrentChannel.Value = null; JoinedChannels.Remove(channel); if (channel.Joined.Value) { api.Queue(new LeaveChannelRequest(channel, api.LocalUser)); channel.Joined.Value = false; } } public void APIStateChanged(APIAccess api, APIState state) { switch (state) { case APIState.Online: fetchUpdates(); break; default: fetchMessagesScheduleder?.Cancel(); fetchMessagesScheduleder = null; break; } } private long lastMessageId; private const int update_poll_interval = 1000; private bool channelsInitialised; private void fetchUpdates() { fetchMessagesScheduleder?.Cancel(); fetchMessagesScheduleder = Scheduler.AddDelayed(() => { var fetchReq = new GetUpdatesRequest(lastMessageId); fetchReq.Success += updates => { if (updates?.Presence != null) { foreach (var channel in updates.Presence) { // we received this from the server so should mark the channel already joined. JoinChannel(channel, true); } //todo: handle left channels handleChannelMessages(updates.Messages); foreach (var group in updates.Messages.GroupBy(m => m.ChannelId)) JoinedChannels.FirstOrDefault(c => c.Id == group.Key)?.AddNewMessages(group.ToArray()); lastMessageId = updates.Messages.LastOrDefault()?.Id ?? lastMessageId; } if (!channelsInitialised) { channelsInitialised = true; // we want this to run after the first presence so we can see if the user is in any channels already. initializeChannels(); } fetchUpdates(); }; fetchReq.Failure += delegate { fetchUpdates(); }; api.Queue(fetchReq); }, update_poll_interval); } [BackgroundDependencyLoader] private void load(IAPIProvider api) { this.api = api; api.Register(this); } } /// /// An exception thrown when a channel could not been found. /// public class ChannelNotFoundException : Exception { public ChannelNotFoundException(string channelName) : base($"A channel with the name {channelName} could not be found.") { } } }