package chatty.util.ffz; import chatty.Chatty; import chatty.Helper; import chatty.util.JSONUtil; import chatty.util.UrlRequest; import chatty.util.api.Emoticon; import chatty.util.api.EmoticonUpdate; import chatty.util.settings.Settings; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.Map; import java.util.Set; import java.util.logging.Logger; import org.json.simple.JSONArray; import org.json.simple.JSONObject; import org.json.simple.parser.JSONParser; /** * FFZ Websocket Manager. Handling the more top-level stuff of parsing actual * commands and maintaining the list of rooms to sub to. * * @author tduva */ public class WebsocketManager { private final static Logger LOGGER = Logger.getLogger(WebsocketManager.class.getName()); private final static String VERSION = "chatty_"+Chatty.VERSION; private final Set<String> rooms = Collections.synchronizedSet(new HashSet<String>()); private final Map<String, Set<Integer>> prevEmotesets = new HashMap<>(); private final WebsocketClient c; private final JSONParser parser = new JSONParser(); private final FrankerFaceZListener listener; private long serverTimeOffset; private final Settings settings; /** * The username that was send with the "setuser" command, during this * connection. */ private String setUser; public WebsocketManager(final FrankerFaceZListener listener, final Settings settings) { this.listener = listener; this.settings = settings; /** * These methods may be called out of a lock on the WebsocketClient * instance, so to be safe this usually shouldn't call anything that * creates a lock which may also access the WebsocketClient instance. */ c = new WebsocketClient(new WebsocketClient.MessageHandler() { @Override public void handleReceived(String text) { listener.wsInfo("<-- "+text); } @Override public void handleSent(String sent) { listener.wsInfo("--> "+sent); } @Override public void handleCommand(int id, String command, String params, String originCommand) { if (id == 1 && command.equals("ok")) { // ok ["uuid",serverTime] parseHelloResponse(params); } else if (command.equals("follow_sets")) { // follow_sets {\"sirstendec\": [3779]} parseFollowsets(params); } else if (command.equals("do_authorize")) { // do_authorize "string" parseDoAuthorize(params); } else if (originCommand.equals("update_follow_buttons")) { if (command.equals("ok")) { // ok {"updated_clients":1} parseFollowingResponse(params); } else if (command.equals("error")) { listener.wsUserInfo("Failed updating follow buttons: "+params); } } } /** * This is still locked with the WebsocketClient instance, which * ensures this is completed before anything else can be send (e.g. * by addRoom()). */ @Override public void handleConnect() { setUser = null; c.sendCommand("hello", JSONUtil.listToJSON(VERSION, false)); for (String room : getRooms()) { subRoom(room); } c.sendCommand("ready", "0"); } }); } private static String[] getServers() { return new String[] { "catbag.frankerfacez.com", "andknuckles.frankerfacez.com", "tuturu.frankerfacez.com" }; } /** * Thread-safe defensive copy of the current set of rooms. * * @return */ private Set<String> getRooms() { synchronized (rooms) { return new HashSet<>(rooms); } } public String getStatus() { return c.getStatus(); } public void connect() { if (!settings.getBoolean("ffz") || !settings.getBoolean("ffzEvent")) { return; } c.connect(getServers()); } public void disconnect() { c.disonnect(); } /** * Subscribe to a room. * * @param room */ public synchronized void addRoom(String room) { if (!Helper.validateStream(room)) { return; } connect(); room = room.toLowerCase(); if (rooms.add(room)) { subRoom(room); } } /** * Remove subscription to a room. This also removes all current FFZ EVENT * emotes from that room. * * @param room */ public synchronized void removeRoom(String room) { if (!Helper.validateStream(room)) { return; } room = room.toLowerCase(); if (rooms.remove(room)) { unsubRoom(room); removeEmotes(room); prevEmotesets.remove(room); } } public synchronized void setFollowing(String user, String room, String following) { String[] split = following.split(","); JSONArray rooms = new JSONArray(); for (String item : split) { item = item.trim(); if (Helper.validateStream(item)) { rooms.add(item); } } JSONArray root = new JSONArray(); root.add(room); root.add(rooms); if (!user.equals(setUser)) { c.sendCommand("setuser", "\""+user+"\""); setUser = user; } c.sendCommand("update_follow_buttons", root.toJSONString()); } private void subRoom(String room) { c.sendCommand("sub", "\"room."+room+"\""); } private void unsubRoom(String room) { c.sendCommand("unsub", "\"room."+room+"\""); } private void parseHelloResponse(String json) { try { JSONArray data = (JSONArray) parser.parse(json); String clientId = (String)data.get(0); long serverTime = ((Number)data.get(1)).longValue(); serverTimeOffset = System.currentTimeMillis() - serverTime; LOGGER.info("[FFZ-WS] Server Time Offset: "+serverTimeOffset); } catch (Exception ex) { LOGGER.warning(String.format("[FFZ-WS] Error parsing 'hello' response: %s [%s]", ex, json)); } } private void parseDoAuthorize(String json) { try { String code = (String) parser.parse(json); listener.authorizeUser(code); } catch (Exception ex) { LOGGER.warning(String.format("[FFZ-WS] Error parsing 'do_authorize' response: %s [%s]", ex, json)); } } /** * Parses the response to the "/ffz following" command. * * @param json */ private void parseFollowingResponse(String json) { try { JSONObject root = (JSONObject) parser.parse(json); int updatedClients = ((Number)root.get("updated_clients")).intValue(); listener.wsUserInfo("Updated following buttons for "+updatedClients+" users."); } catch (Exception ex) { LOGGER.warning(String.format("[FFZ-WS] Error parsing 'update_follow_buttons' response: %s [%s]", ex, json)); } } /** * Parses the "follow_sets" message from the server and updates the emotes * accordingly, but only if the emotesets have changed. * * @param json */ private void parseFollowsets(String json) { try { JSONObject data = (JSONObject) parser.parse(json); for (Object key : data.keySet()) { String room = ((String)key).toLowerCase(); Set<Integer> emotesets = new HashSet<>(); for (Object set : (JSONArray)data.get(key)) { emotesets.add(((Number)set).intValue()); } if (!prevEmotesets.containsKey(room) || !prevEmotesets.get(room).equals(emotesets)) { fetchEmotes(room, emotesets); prevEmotesets.put(room, emotesets); } } } catch (Exception ex) { LOGGER.warning(String.format("[FFZ-WS] Error parsing 'follow_sets': %s [%s]", ex, json)); } } /** * Fetches all emotes of the given emotesets, useable in the given room and * sends them to the listener (removing previous EVENT emotes in that room). * * @param room The room the emotes will be useable in * @param emotesets The set of FFZ emotesets */ private void fetchEmotes(String room, Set<Integer> emotesets) { Set<Emoticon> result = new HashSet<>(); for (int set : emotesets) { Set<Emoticon> fetched = fetchEmoteSet(room, set); for (Emoticon emoteToAdd : fetched) { // Add info to already existing emotes for (Emoticon emote : result) { if (emote.equals(emoteToAdd)) { emote.addInfos(emoteToAdd.getInfos()); break; } } // Add emote to result if not already added (Set) result.add(emoteToAdd); } } EmoticonUpdate update = new EmoticonUpdate(result, Emoticon.Type.FFZ, Emoticon.SubType.EVENT, room); listener.channelEmoticonsReceived(update); } /** * Sends an update to the listener to remove all FFZ EVENT emotes from the * given room. * * @param room The channel the emotes should be removed from */ private void removeEmotes(String room) { EmoticonUpdate update = new EmoticonUpdate(null, Emoticon.Type.FFZ, Emoticon.SubType.EVENT, room); listener.channelEmoticonsReceived(update); } /** * Get the emotes from a specific emoteset, useable in the given room. * * @param room The channel the emotes should be useable in * @param emoteset The FFZ emoteset to fetch * @return A set of emotes or an empty set if an error occured */ private Set<Emoticon> fetchEmoteSet(String room, int emoteset) { UrlRequest r = new UrlRequest("https://api.frankerfacez.com/v1/set/"+emoteset); r.run(); if (r.getResult() != null) { Set<Emoticon> emotes = FrankerFaceZParsing.parseSetEmotes( r.getResult(), Emoticon.SubType.EVENT, room); return emotes; } return new HashSet<>(); } }