/** * Copyright (c) 2005-2017, KoLmafia development team * http://kolmafia.sourceforge.net/ * All rights reserved. * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions * are met: * * [1] Redistributions of source code must retain the above copyright * notice, this list of conditions and the following disclaimer. * [2] Redistributions in binary form must reproduce the above copyright * notice, this list of conditions and the following disclaimer in * the documentation and/or other materials provided with the * distribution. * [3] Neither the name "KoLmafia" nor the names of its contributors may * be used to endorse or promote products derived from this software * without specific prior written permission. * * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS * FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE * COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE * POSSIBILITY OF SUCH DAMAGE. */ package net.sourceforge.kolmafia.chat; import java.util.ArrayList; import java.util.Date; import java.util.Iterator; import java.util.LinkedList; import java.util.List; import net.sourceforge.kolmafia.KoLCharacter; import net.sourceforge.kolmafia.RequestThread; import net.sourceforge.kolmafia.StaticEntity; import net.sourceforge.kolmafia.listener.NamedListenerRegistry; import net.sourceforge.kolmafia.preferences.Preferences; import net.sourceforge.kolmafia.request.ChatRequest; import net.sourceforge.kolmafia.request.GenericRequest; import net.sourceforge.kolmafia.utilities.PauseObject; import net.sourceforge.kolmafia.utilities.RollingLinkedList; import net.sourceforge.kolmafia.utilities.StringUtilities; import org.json.JSONException; import org.json.JSONObject; import org.json.JSONArray; public class ChatPoller extends Thread { // The most recent HistoryEntries we processed private static final LinkedList<HistoryEntry> chatHistoryEntries = new RollingLinkedList<HistoryEntry>( 25 ); // The sequence number of the last HistoryEntry we have added public static long localLastSeen = 0; // The sequence number of the last poll from a chat client, either our // chat GUI or the browser public static long serverLastSeen = 0; // *** public static long localLastSent = 0; // Milliseconds between polls. Extracted from the Javascript source on // Sept 30, 2014 private static int LCHAT_DELAY_NORMAL = 5000; private static int LCHAT_DELAY_PAUSED = 30000; private static int MCHAT_DELAY_NORMAL = 5000; private static int MCHAT_DELAY_PAUSED = 10000; // lchat and mchat like to go into "away" mode after 15 minutes. If // you are running GUI chat and browser chat at the same time, let the // browser chat go first. private static int AWAY_MODE_THRESHOLD = ( 16 * 60 * 1000 ); private static final String AWAY_MESSAGE = "You are now in away mode, chat will update more slowly until you say something."; private static final String BACK_MESSAGE = "Welcome back! Away mode disabled."; // timestamps public static Date lastServerPoll = new Date( 0 ); public static Date lastSentMessage = new Date( 0 ); public static long lastLocalSent = 0; private static String rightClickMenu = ""; public static final void reset() { ChatPoller.chatHistoryEntries.clear(); ChatPoller.serverLastSeen = 0; ChatPoller.localLastSeen = 0; ChatPoller.localLastSent = 0; } // The instance of the chat poller currently serving the chat GUI private static ChatPoller INSTANCE = null; // The GUI chat poller is running private boolean running = false; // The GUI chat poller is in "away" mode or not private boolean paused = false; // The delay between polls private int delay = ChatPoller.LCHAT_DELAY_NORMAL; public static ChatPoller getInstance() { return ChatPoller.INSTANCE; } public static void startInstance() { if ( ChatPoller.INSTANCE == null ) { ChatPoller.INSTANCE = new ChatPoller(); ChatPoller.INSTANCE.start(); } } public static void stopInstance() { if ( ChatPoller.INSTANCE != null ) { ChatPoller.INSTANCE.running = false; ChatPoller.INSTANCE = null; } } // Things that KoL tells us public static void setServerLast( final long last ) { // The "last" field of a JSON mchat response ChatPoller.serverLastSeen = last; } public static void setServerDelay( int delay ) { // The "delay" field of a JSON mchat response if ( ChatPoller.INSTANCE != null ) { // mchat does not choose delay; KoL specifies it after each poll // However, mchat rejects wildly inappropriate values. So do we. if ( delay < 1 || delay > 60000 ) { delay = ChatPoller.MCHAT_DELAY_NORMAL; } ChatPoller.INSTANCE.delay = delay; } } public static void pauseChat( final boolean paused, final boolean mchat ) { if ( ChatPoller.INSTANCE != null ) { if ( paused ) { ChatPoller.INSTANCE.pause( mchat ); } else { ChatPoller.INSTANCE.unpause( mchat ); } } } private void pause( final boolean mchat ) { if ( !this.paused ) { this.paused = true; this.delay = mchat ? ChatPoller.MCHAT_DELAY_PAUSED : ChatPoller.LCHAT_DELAY_PAUSED; // mchat gives us the AWAY message as an event if ( !mchat ) { // If this is not from mchat, craft our own event EventMessage message = new EventMessage( ChatPoller.AWAY_MESSAGE, "green" ); ChatManager.broadcastEvent( message ); } NamedListenerRegistry.fireChange( "[chatAway]" ); } } private void unpause( final boolean mchat ) { if ( this.paused ) { this.paused = false; this.delay = mchat ? ChatPoller.MCHAT_DELAY_NORMAL : ChatPoller.LCHAT_DELAY_NORMAL; // mchat gives us the BACK message as an event if ( !mchat ) { // If this is not from mchat, craft our own event EventMessage message = new EventMessage( ChatPoller.BACK_MESSAGE, "green" ); ChatManager.broadcastEvent( message ); } NamedListenerRegistry.fireChange( "[chatAway]" ); } } public static boolean isPaused() { return ChatPoller.INSTANCE != null && ChatPoller.INSTANCE.paused; } public static void serverPolled() { ChatPoller.lastServerPoll = new Date(); } public static void sentMessage( final boolean mchat ) { ChatPoller.lastSentMessage = new Date(); if ( ChatPoller.INSTANCE != null ) { ChatPoller.INSTANCE.unpause( mchat ); } } // The executable method which polls using the "lchat" protocol @Override public void run() { PauseObject pauser = new PauseObject(); this.running = true; this.paused = false; // Since we just entered chat, pretend that we just sent a message ChatPoller.lastSentMessage = new Date(); while ( this.running ) { Date now = new Date(); try { // Only poll if the browser has not polled recently enough long serverLast; synchronized ( ChatPoller.lastServerPoll ) { serverLast = ChatPoller.lastServerPoll.getTime(); } if ( serverLast == 0 || ( now.getTime() - serverLast ) >= this.delay ) { List<HistoryEntry> entries = ChatPoller.getEntries( ChatPoller.localLastSeen, false, this.paused ); } } catch ( Exception e ) { StaticEntity.printStackTrace(e); } if ( !this.paused && ( now.getTime() - ChatPoller.lastSentMessage.getTime() ) > ChatPoller.AWAY_MODE_THRESHOLD ) { this.pause( false ); } pauser.pause( this.delay ); } } public synchronized static void addEntry( ChatMessage message ) { HistoryEntry entry = new HistoryEntry( message, ++ChatPoller.localLastSent ); synchronized ( ChatPoller.chatHistoryEntries ) { ChatPoller.chatHistoryEntries.add( entry ); } ChatManager.processMessages( entry.getChatMessages() ); } public synchronized static void addSentEntry( final String responseText, final boolean isRelayRequest ) { SentMessageEntry entry = new SentMessageEntry( responseText, ++ChatPoller.localLastSent, isRelayRequest ); entry.executeAjaxCommand(); synchronized ( ChatPoller.chatHistoryEntries ) { ChatPoller.chatHistoryEntries.add( entry ); } } private static final void addValidEntry( final List<HistoryEntry> newEntries, final HistoryEntry entry, final boolean isRelayRequest ) { if ( !( entry instanceof SentMessageEntry ) ) { newEntries.add( entry ); return; } if ( !isRelayRequest ) { return; } SentMessageEntry sentEntry = (SentMessageEntry) entry; if ( sentEntry.isRelayRequest() ) { return; } newEntries.add( entry ); return; } public synchronized static List<HistoryEntry> getOldEntries( final boolean isRelayRequest ) { List<HistoryEntry> newEntries = new ArrayList<HistoryEntry>(); final long lastSeen = ChatPoller.localLastSeen; synchronized ( ChatPoller.chatHistoryEntries ) { Iterator<HistoryEntry> entryIterator = ChatPoller.chatHistoryEntries.iterator(); while ( entryIterator.hasNext() ) { HistoryEntry entry = entryIterator.next(); if ( entry.getLocalLastSeen() > lastSeen ) { ChatPoller.addValidEntry( newEntries, entry, isRelayRequest ); while ( entryIterator.hasNext() ) { ChatPoller.addValidEntry( newEntries, entryIterator.next(), isRelayRequest ); } } } } ChatPoller.localLastSeen = ChatPoller.localLastSent; return newEntries; } public synchronized static List<HistoryEntry> getEntries( final long lastSeen, final boolean isRelayRequest, final boolean paused ) { List<HistoryEntry> newEntries = ChatPoller.getOldEntries( isRelayRequest ); if ( ChatManager.getCurrentChannel() == null ) { ChatSender.sendMessage( null, "/listen", true ); } ChatRequest request = new ChatRequest( ChatPoller.serverLastSeen, false, paused ); request.run(); HistoryEntry entry = new HistoryEntry( request.responseText, ++ChatPoller.localLastSent ); ChatPoller.localLastSeen = ChatPoller.localLastSent; ChatPoller.setServerLast( entry.getServerLastSeen() ); newEntries.add( entry ); synchronized ( ChatPoller.chatHistoryEntries ) { ChatPoller.chatHistoryEntries.add( entry ); } ChatManager.processMessages( entry.getChatMessages() ); return newEntries; } public static final String getRightClickMenu() { if ( ChatPoller.rightClickMenu.equals( "" ) ) { GenericRequest request = new GenericRequest( "lchat.php" ); RequestThread.postRequest( request ); int actionIndex = request.responseText.indexOf( "actions = {" ); if ( actionIndex != -1 ) { ChatPoller.rightClickMenu = request.responseText.substring( actionIndex, request.responseText.indexOf( ";", actionIndex ) + 1 ); } } return ChatPoller.rightClickMenu; } private static final boolean messageAlreadySeen( final String recipient, final String content, final long localLastSeen ) { synchronized ( ChatPoller.chatHistoryEntries ) { for ( HistoryEntry entry : ChatPoller.chatHistoryEntries ) { if ( entry instanceof SentMessageEntry && entry.getLocalLastSeen() > localLastSeen ) { for ( ChatMessage message : entry.getChatMessages() ) { if ( recipient.equals( message.getRecipient() ) && content.equals( message.getContent() ) ) { return true; } } } } } return false; } public static List<ChatMessage> parseNewChat( final String responseData ) { List<ChatMessage> messages = new LinkedList<ChatMessage>(); try { ChatPoller.parseNewChat( messages, new JSONObject( responseData ), "", ChatPoller.localLastSeen, true ); } catch ( JSONException e ) { e.printStackTrace(); } return messages; } public static List<ChatMessage> parseNewChat( final List<ChatMessage> messages, JSONObject obj, final String sent, final long localLastSeen, final boolean debug ) throws JSONException { JSONArray msgs = obj.getJSONArray( "msgs" ); for ( int i = 0; i < msgs.length(); i++ ) { JSONObject msg = msgs.getJSONObject( i ); // If we have already seen this message in the chat GUI, skip it. long mid = msg.optLong( "mid" ); if ( !debug && mid != 0 && mid <= ChatPoller.serverLastSeen ) { continue; } String type = msg.getString( "type" ); boolean pub = type.equals( "public" ); String formatString = msg.optString( "format" ); int format = formatString == null ? 0 : StringUtilities.parseInt( formatString ); // From mchat.js: // // type = "public" format = "0" -> normal public chat message // type = "public" format = "1" -> /em public chat message // type = "public" format = "2" -> System Message // type = "public" format = "3" -> Mod Warning // type = "public" format = "4" -> Mod Announcement // type = "public" format = "98" -> event // type = "public" format = "99" -> Welcome message // type = "private" -> private chat message // type = "event" -> event // type = "system" -> System Message // // I have never seen the "public" versions of // System Message or Event. Those are probably // obsolete, now that they have their own types JSONObject whoObj = msg.optJSONObject( "who" ); String sender = whoObj != null ? whoObj.getString( "name" ) : null; String senderId = whoObj != null ? whoObj.getString( "id" ) : null; boolean mine = KoLCharacter.getPlayerId().equals( senderId ); JSONObject forObj = msg.optJSONObject( "for" ); String recipient = forObj != null ? forObj.optString( "name" ) : null; String content = msg.optString( "msg", null ); if ( type.equals( "event" ) ) { // {"type":"event","msg":"You are now in away mode, chat will update more slowly until you say something.","notnew":1,"time":1411683685} // {"type":"event","msg":"Welcome back! Away mode disabled.","time":1411683893} if ( !debug && content != null ) { if ( content.startsWith( "You are now in away mode" ) ) { ChatPoller.pauseChat( true, true ); } else if ( content.contains( "Away mode disabled" ) ) { ChatPoller.pauseChat( false, true ); } // TODO: handle other events } messages.add( new EventMessage( content, "green" ) ); continue; } if ( type.equals( "system" ) ) { messages.add( new SystemMessage( content ) ); continue; } if ( sender == null ) { if ( !debug ) { ChatSender.processResponse( messages, content, sent ); continue; } } else if ( sender.equals( "HMC Radio" ) ) { messages.add( new HugglerMessage( content ) ); continue; } if ( recipient == null ) { if ( pub ) { String channel = "/" + msg.getString( "channel" ); if ( sender.equals( "Mod Announcement" ) || sender.equals( "Mod Warning" ) ) { messages.add( new ModeratorMessage( channel, sender, senderId, content ) ); continue; } recipient = channel; } else { recipient = KoLCharacter.getUserName().replaceAll( " ", "_" ); } } // Apparently isAction corresponds to /em commands. boolean isAction = format == 1; if ( isAction ) { // username ends with "</b></font></a> "; remove trailing </i> content = content.substring( content.indexOf("</a>" ) + 5, content.length() - 4 ); } if ( pub && mine && ChatPoller.messageAlreadySeen( recipient, content, localLastSeen ) ) { continue; } messages.add( new ChatMessage( sender, recipient, content, isAction ) ); } return messages; } public static void handleNewChat( final String responseData, final String sent, final long localLastSeen ) { try { List<ChatMessage> messages = new LinkedList<ChatMessage>(); JSONObject obj = new JSONObject( responseData ); // note: output is where /who, /listen, + various game commands // (/use etc) output goes. May exist. String output = obj.optString( "output", null ); if ( output != null ) { // TODO: strip channelname so /cli works again. ChatSender.processResponse( messages, output, sent ); } ChatPoller.parseNewChat( messages, obj, sent, localLastSeen, false ); if ( obj.has( "last" ) ) { // Remember the last timestamp the server gave us ChatPoller.setServerLast( obj.getLong( "last" ) ); } if ( obj.has( "delay" ) ) { // Set the chat GUI's delay to whatever KoL says it should be. ChatPoller.setServerDelay( obj.getInt( "delay" ) ); } ChatManager.processMessages( messages ); } catch ( JSONException e ) { e.printStackTrace(); } } }