package com.faforever.client.chat;
import com.faforever.client.i18n.I18n;
import com.faforever.client.net.ConnectionState;
import com.faforever.client.notification.NotificationService;
import com.faforever.client.notification.TransientNotification;
import com.faforever.client.player.PlayerService;
import com.faforever.client.preferences.ChatPrefs;
import com.faforever.client.preferences.PreferencesService;
import com.faforever.client.remote.FafService;
import com.faforever.client.remote.domain.SocialMessage;
import com.faforever.client.task.CompletableTask;
import com.faforever.client.task.TaskService;
import com.faforever.client.user.UserService;
import com.faforever.client.util.IdenticonUtil;
import com.google.common.collect.ImmutableSortedSet;
import com.google.common.eventbus.EventBus;
import com.google.common.hash.Hashing;
import javafx.application.Platform;
import javafx.beans.property.IntegerProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.ReadOnlyIntegerProperty;
import javafx.beans.property.SimpleIntegerProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.collections.MapChangeListener;
import javafx.collections.ObservableMap;
import javafx.concurrent.Task;
import javafx.scene.paint.Color;
import org.jetbrains.annotations.NotNull;
import org.pircbotx.Configuration;
import org.pircbotx.PircBotX;
import org.pircbotx.User;
import org.pircbotx.UserHostmask;
import org.pircbotx.UtilSSLSocketFactory;
import org.pircbotx.exception.IrcException;
import org.pircbotx.hooks.Event;
import org.pircbotx.hooks.events.ActionEvent;
import org.pircbotx.hooks.events.ConnectEvent;
import org.pircbotx.hooks.events.DisconnectEvent;
import org.pircbotx.hooks.events.JoinEvent;
import org.pircbotx.hooks.events.MessageEvent;
import org.pircbotx.hooks.events.NoticeEvent;
import org.pircbotx.hooks.events.OpEvent;
import org.pircbotx.hooks.events.PartEvent;
import org.pircbotx.hooks.events.PrivateMessageEvent;
import org.pircbotx.hooks.events.QuitEvent;
import org.pircbotx.hooks.events.TopicEvent;
import org.pircbotx.hooks.events.UserListEvent;
import org.pircbotx.hooks.types.GenericEvent;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
import javax.annotation.Resource;
import java.io.IOException;
import java.lang.invoke.MethodHandles;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import static com.faforever.client.chat.ChatColorMode.CUSTOM;
import static com.faforever.client.chat.ChatColorMode.RANDOM;
import static com.faforever.client.task.CompletableTask.Priority.HIGH;
import static com.github.nocatch.NoCatch.noCatch;
import static java.lang.String.format;
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.util.Locale.US;
import static javafx.collections.FXCollections.observableHashMap;
import static org.apache.commons.lang3.StringUtils.containsIgnoreCase;
public class PircBotXChatService implements ChatService {
private static final Logger logger = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
private static final int SOCKET_TIMEOUT = 10000;
private final Map<Class<? extends GenericEvent>, ArrayList<ChatEventListener>> eventListeners;
/**
* Maps channels by name.
*/
private final ObservableMap<String, Channel> channels;
private final ObservableMap<String, ChatUser> chatUsersByName;
private final ObjectProperty<ConnectionState> connectionState;
private final IntegerProperty unreadMessagesCount;
@Resource
PreferencesService preferencesService;
@Resource
UserService userService;
@Resource
PlayerService playerService;
@Resource
TaskService taskService;
@Resource
FafService fafService;
@Resource
I18n i18n;
@Resource
PircBotXFactory pircBotXFactory;
@Resource
NotificationService notificationService;
@Resource
ThreadPoolExecutor threadPoolExecutor;
@Resource
EventBus eventBus;
@Value("${irc.host}")
String ircHost;
@Value("${irc.port}")
int ircPort;
@Value("${irc.defaultChannel}")
String defaultChannelName;
@Value("${irc.reconnectDelay}")
int reconnectDelay;
private Configuration configuration;
private PircBotX pircBotX;
private CountDownLatch identifiedLatch;
private Task<Void> connectionTask;
/**
* A list of channels the server wants us to join.
*/
private List<String> autoChannels;
/**
* Indicates whether the "auto channels" already have been joined. This is needed because we don't want to auto join channels after a reconnect that the user left before the reconnect.
*/
private boolean autoChannelsJoined;
public PircBotXChatService() {
connectionState = new SimpleObjectProperty<>();
eventListeners = new ConcurrentHashMap<>();
channels = observableHashMap();
chatUsersByName = observableHashMap();
unreadMessagesCount = new SimpleIntegerProperty();
identifiedLatch = new CountDownLatch(1);
}
@PostConstruct
void postConstruct() {
fafService.addOnMessageListener(SocialMessage.class, this::onSocialMessage);
connectionState.addListener((observable, oldValue, newValue) -> {
switch (newValue) {
case DISCONNECTED:
case CONNECTING:
onDisconnected();
break;
}
});
addEventListener(NoticeEvent.class, this::onNotice);
addEventListener(ConnectEvent.class, event -> connectionState.set(ConnectionState.CONNECTED));
addEventListener(DisconnectEvent.class, event -> connectionState.set(ConnectionState.DISCONNECTED));
addEventListener(UserListEvent.class, event -> onChatUserList(event.getChannel().getName(), chatUsers(event.getUsers())));
addEventListener(JoinEvent.class, event -> onUserJoinedChannel(event.getChannel().getName(), getOrCreateChatUser(event.getUser())));
addEventListener(PartEvent.class, event -> onChatUserLeftChannel(event.getChannel().getName(), event.getUser().getNick()));
addEventListener(QuitEvent.class, event -> onChatUserQuit(event.getUser().getNick()));
addEventListener(TopicEvent.class, event -> getOrCreateChannel(event.getChannel().getName()).setTopic(event.getTopic()));
addEventListener(OpEvent.class, event -> {
User recipient = event.getRecipient();
if (recipient != null) {
onModeratorSet(event.getChannel().getName(), recipient.getNick());
}
});
userService.loggedInProperty().addListener((observable, oldValue, newValue) -> {
if (newValue) {
connect();
} else {
disconnect();
autoChannelsJoined = false;
}
});
ChatPrefs chatPrefs = preferencesService.getPreferences().getChat();
chatPrefs.userToColorProperty().addListener(
(MapChangeListener<? super String, ? super Color>) change -> preferencesService.store()
);
chatPrefs.chatColorModeProperty().addListener((observable, oldValue, newValue) -> {
synchronized (chatUsersByName) {
switch (newValue) {
case CUSTOM:
chatUsersByName.values().stream()
.filter(chatUser -> chatPrefs.getUserToColor().containsKey(chatUser.getUsername().toLowerCase(US)))
.forEach(chatUser -> chatUser.setColor(chatPrefs.getUserToColor().get(chatUser.getUsername().toLowerCase(US))));
break;
case RANDOM:
for (ChatUser chatUser : chatUsersByName.values()) {
chatUser.setColor(ColorGeneratorUtil.generateRandomColor(chatUser.getUsername().hashCode()));
}
break;
default:
for (ChatUser chatUser : chatUsersByName.values()) {
chatUser.setColor(null);
}
}
}
});
}
private void onNotice(NoticeEvent event) {
Configuration config = event.getBot().getConfiguration();
UserHostmask hostmask = event.getUserHostmask();
if (config.getNickservOnSuccess() != null && containsIgnoreCase(hostmask.getHostmask(), config.getNickservNick())) {
String message = event.getMessage();
if (containsIgnoreCase(message, config.getNickservOnSuccess()) || containsIgnoreCase(message, "registered under your account")) {
onIdentified();
} else if (message.contains("isn't registered")) {
sendMessageInBackground("NickServ", format("register %s %s@users.faforever.com", getPassword(), userService.getUsername()));
}
}
}
private void onIdentified() {
identifiedLatch.countDown();
if (!autoChannelsJoined) {
joinAutoChannels();
} else {
synchronized (channels) {
channels.keySet().forEach(this::joinChannel);
}
}
}
private void joinAutoChannels() {
if (autoChannels == null) {
return;
}
autoChannels.forEach(this::joinChannel);
autoChannelsJoined = true;
}
private void onDisconnected() {
synchronized (channels) {
channels.values().forEach(Channel::clearUsers);
}
}
private <T extends GenericEvent> void addEventListener(Class<T> eventClass, ChatEventListener<T> listener) {
if (!eventListeners.containsKey(eventClass)) {
eventListeners.put(eventClass, new ArrayList<>());
}
eventListeners.get(eventClass).add(listener);
}
private void onChatUserList(String channelName, List<ChatUser> users) {
getOrCreateChannel(channelName).addUsers(users);
}
private List<ChatUser> chatUsers(ImmutableSortedSet<User> users) {
return users.stream().map(this::getOrCreateChatUser).collect(Collectors.toList());
}
private void onUserJoinedChannel(String channelName, ChatUser chatUser) {
String username = chatUser.getUsername();
getOrCreateChannel(channelName).addUser(chatUser);
PlayerInfoBean player = playerService.getPlayerForUsername(username);
if (player != null && player.getSocialStatus() == SocialStatus.FRIEND) {
notificationService.addNotification(
new TransientNotification(
i18n.get("friend.nowOnlineNotification.title", username),
i18n.get("friend.nowOnlineNotification.action"),
IdenticonUtil.createIdenticon(player.getId()),
event -> eventBus.post(new InitiatePrivateChatEvent(username))
));
}
}
private void onChatUserLeftChannel(String channelName, String username) {
getOrCreateChannel(channelName).removeUser(username);
if (userService.getUsername().equalsIgnoreCase(username)) {
channels.remove(channelName);
}
}
private void onChatUserQuit(String username) {
synchronized (channels) {
channels.values().forEach(channel -> channel.removeUser(username));
}
synchronized (chatUsersByName) {
chatUsersByName.remove(username);
}
PlayerInfoBean player = playerService.getPlayerForUsername(username);
if (player != null && player.getSocialStatus() == SocialStatus.FRIEND) {
notificationService.addNotification(
new TransientNotification(
i18n.get("friend.nowOfflineNotification.title", username), "",
IdenticonUtil.createIdenticon(player.getId())
));
}
}
private void onModeratorSet(String channelName, String username) {
getOrCreateChannel(channelName).setModerator(username);
}
private void init() {
String username = userService.getUsername();
configuration = new Configuration.Builder()
.setName(username)
.setLogin(String.valueOf(userService.getUid()))
.setRealName(username)
.addServer(ircHost, ircPort)
.setSocketFactory(new UtilSSLSocketFactory().trustAllCertificates())
.setAutoSplitMessage(true)
.setEncoding(UTF_8)
.addListener(this::onEvent)
.setSocketTimeout(SOCKET_TIMEOUT)
.setMessageDelay(0)
.setAutoReconnectDelay(reconnectDelay)
.setNickservPassword(getPassword())
.setAutoReconnect(true)
.buildConfiguration();
pircBotX = pircBotXFactory.createPircBotX(configuration);
}
@NotNull
private String getPassword() {
return Hashing.md5().hashString(userService.getPassword(), UTF_8).toString();
}
private void onSocialMessage(SocialMessage socialMessage) {
if (!autoChannelsJoined) {
this.autoChannels = new ArrayList<>(socialMessage.getChannels());
autoChannels.add(0, defaultChannelName);
joinAutoChannels();
}
}
@SuppressWarnings("unchecked")
private void onEvent(Event event) {
if (!eventListeners.containsKey(event.getClass())) {
return;
}
eventListeners.get(event.getClass()).forEach(listener -> listener.onEvent(event));
}
public void addOnMessageListener(Consumer<ChatMessage> listener) {
addEventListener(MessageEvent.class, event -> {
User user = event.getUser();
if (user == null) {
logger.warn("Action event without user: {}", event);
return;
}
String source;
org.pircbotx.Channel channel = event.getChannel();
if (channel == null) {
source = user.getNick();
} else {
source = channel.getName();
}
listener.accept(
new ChatMessage(source, Instant.ofEpochMilli(event.getTimestamp()), user.getNick(), event.getMessage(), false)
);
});
addEventListener(ActionEvent.class, event -> {
User user = event.getUser();
if (user == null) {
logger.warn("Action event without user: {}", event);
return;
}
String source;
org.pircbotx.Channel channel = event.getChannel();
if (channel == null) {
source = user.getNick();
} else {
source = channel.getName();
}
listener.accept(
new ChatMessage(source, Instant.ofEpochMilli(event.getTimestamp()), user.getNick(), event.getMessage(), true)
);
});
}
@Override
public void addOnPrivateChatMessageListener(Consumer<ChatMessage> listener) {
addEventListener(PrivateMessageEvent.class,
event -> {
User user = event.getUser();
if (user == null) {
logger.warn("Private message without user: {}", event);
return;
}
listener.accept(
new ChatMessage(user.getNick(), Instant.ofEpochMilli(event.getTimestamp()), user.getNick(), event.getMessage())
);
}
);
}
@Override
public void connect() {
init();
connectionTask = new Task<Void>() {
@Override
protected Void call() throws Exception {
while (!isCancelled()) {
try {
connectionState.set(ConnectionState.CONNECTING);
Configuration.ServerEntry server = configuration.getServers().get(0);
logger.info("Connecting to IRC at {}:{}", server.getHostname(), server.getPort());
pircBotX.startBot();
} catch (IOException | IrcException | RuntimeException e) {
connectionState.set(ConnectionState.DISCONNECTED);
}
}
return null;
}
};
threadPoolExecutor.execute(connectionTask);
}
@Override
public void disconnect() {
logger.info("Disconnecting from IRC");
if (connectionTask != null) {
connectionTask.cancel(false);
}
if (pircBotX.isConnected()) {
pircBotX.stopBotReconnect();
pircBotX.sendIRC().quitServer();
channels.clear();
}
identifiedLatch = new CountDownLatch(1);
}
@Override
public CompletionStage<String> sendMessageInBackground(String target, String message) {
return taskService.submitTask(new CompletableTask<String>(HIGH) {
@Override
protected String call() throws Exception {
updateTitle(i18n.get("chat.sendMessageTask.title"));
pircBotX.sendIRC().message(target, message);
return message;
}
}).getFuture();
}
@Override
public Channel getOrCreateChannel(String channelName) {
synchronized (channels) {
if (!channels.containsKey(channelName)) {
channels.put(channelName, new Channel(channelName));
}
return channels.get(channelName);
}
}
@Override
public ChatUser getOrCreateChatUser(String username) {
synchronized (chatUsersByName) {
String lowerUsername = username.toLowerCase(US);
if (!chatUsersByName.containsKey(lowerUsername)) {
ChatPrefs chatPrefs = preferencesService.getPreferences().getChat();
Color color = null;
if (chatPrefs.getChatColorMode() == CUSTOM && chatPrefs.getUserToColor().containsKey(lowerUsername)) {
color = chatPrefs.getUserToColor().get(lowerUsername);
} else if (chatPrefs.getChatColorMode() == RANDOM) {
color = ColorGeneratorUtil.generateRandomColor(lowerUsername.hashCode());
}
chatUsersByName.put(lowerUsername, new ChatUser(username, color));
}
return chatUsersByName.get(lowerUsername);
}
}
@Override
public void addUsersListener(String channelName, MapChangeListener<String, ChatUser> listener) {
getOrCreateChannel(channelName).addUsersListeners(listener);
}
@Override
public void addChatUsersByNameListener(MapChangeListener<String, ChatUser> listener) {
synchronized (chatUsersByName) {
chatUsersByName.addListener(listener);
}
}
@Override
public void addChannelsListener(MapChangeListener<String, Channel> listener) {
synchronized (channels) {
channels.addListener(listener);
}
}
@Override
public void removeUsersListener(String channelName, MapChangeListener<String, ChatUser> listener) {
getOrCreateChannel(channelName).removeUserListener(listener);
}
@Override
public void leaveChannel(String channelName) {
pircBotX.getUserChannelDao().getChannel(channelName).send().part();
}
@Override
public CompletionStage<String> sendActionInBackground(String target, String action) {
return taskService.submitTask(new CompletableTask<String>(HIGH) {
@Override
protected String call() throws Exception {
updateTitle(i18n.get("chat.sendActionTask.title"));
pircBotX.sendIRC().action(target, action);
return action;
}
}).getFuture();
}
@Override
public void joinChannel(String channelName) {
noCatch(() -> identifiedLatch.await());
pircBotX.sendIRC().joinChannel(channelName);
}
@Override
public boolean isDefaultChannel(String channelName) {
return defaultChannelName.equals(channelName);
}
@Override
@PreDestroy
public void close() {
// TODO clean up disconnect() and close()
if (connectionTask != null) {
Platform.runLater(connectionTask::cancel);
}
if (pircBotX != null) {
pircBotX.sendIRC().quitServer();
}
}
@Override
public ChatUser getOrCreateChatUser(User user) {
synchronized (chatUsersByName) {
String username = user.getNick();
String lowerUsername = username.toLowerCase(US);
if (!chatUsersByName.containsKey(lowerUsername)) {
ChatPrefs chatPrefs = preferencesService.getPreferences().getChat();
Color color = null;
if (chatPrefs.getChatColorMode() == CUSTOM && chatPrefs.getUserToColor().containsKey(lowerUsername)) {
color = chatPrefs.getUserToColor().get(lowerUsername);
} else if (chatPrefs.getChatColorMode() == RANDOM) {
color = ColorGeneratorUtil.generateRandomColor(lowerUsername.hashCode());
}
chatUsersByName.put(lowerUsername, ChatUser.fromIrcUser(user, color));
}
return chatUsersByName.get(lowerUsername);
}
}
@Override
public ObjectProperty<ConnectionState> connectionStateProperty() {
return connectionState;
}
@Override
public void reconnect() {
disconnect();
connect();
}
@Override
public void whois(String username) {
pircBotX.sendIRC().whois(username);
}
@Override
public void incrementUnreadMessagesCount(int delta) {
synchronized (unreadMessagesCount) {
unreadMessagesCount.set(unreadMessagesCount.get() + delta);
}
}
@Override
public ReadOnlyIntegerProperty unreadMessagesCount() {
return unreadMessagesCount;
}
interface ChatEventListener<T> {
void onEvent(T event);
}
}