/*
This file is part of leafdigital leafChat.
leafChat is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
leafChat is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with leafChat. If not, see <http://www.gnu.org/licenses/>.
Copyright 2012 Samuel Marshall.
*/
package com.leafdigital.irc;
import java.util.*;
import util.TimeUtils;
import util.xml.XML;
import com.leafdigital.irc.api.*;
import com.leafdigital.prefs.api.*;
import leafchat.core.api.*;
/**
* Watch list monitors users to see who's online.
*/
public class WatchListSingleton implements WatchList
{
private PluginContext context;
/** Map from IRCUserAddress -> PreferencesGroup for user's watch list */
private Map<IRCUserAddress, PreferencesGroup> watchList =
new HashMap<IRCUserAddress, PreferencesGroup>();
/** Map from Server -> ServerInfo for all servers we know about */
private Map<Server, ServerInfo> serverInfo =
new HashMap<Server, ServerInfo>();
private BasicMsgOwner watchMsgOwner=new BasicMsgOwner()
{
@Override
public String getFriendlyName()
{
return "Watched user messages";
}
@Override
public Class<? extends Msg> getMessageClass()
{
return WatchMsg.class;
}
};
private BasicMsgOwner listMsgOwner=new BasicMsgOwner()
{
@Override
public String getFriendlyName()
{
return "Watch list change";
}
@Override
public Class<? extends Msg> getMessageClass()
{
return WatchListChangeMsg.class;
}
};
/** Information stored per-server */
private class ServerInfo
{
private Server s;
int serverMax=0;
/** Map of IRCUserAddress -> Integer (reference count) */
Map<IRCUserAddress, Integer> tempList =
new HashMap<IRCUserAddress, Integer>();
/** Set of IRCUserAddress being WATCHed */
Set<IRCUserAddress> actualWatch = new HashSet<IRCUserAddress>();
/** Set of IRCUserAddress that needs ISON */
Set<IRCUserAddress> ison = new HashSet<IRCUserAddress>();
/** Map of all lower-case nicks that are being watched in some manner => IRCUserAddress mask */
Map<String, IRCUserAddress> actualNicks =
new HashMap<String, IRCUserAddress>();
/** Time at which we last did ISON */
long lastIsonTime;
/** Bulk of last ISON */
int lastIsonBulk;
boolean started=false;
private List<Runnable> backlog = new LinkedList<Runnable>();
ServerInfo(Server s)
{
this.s=s;
}
void startup()
{
// Check whether server supports WATCH
try
{
String max=s.getISupport("WATCH");
if(max!=null)
serverMax=Integer.parseInt(max);
}
catch(NumberFormatException nfe)
{
}
// Add everything to WATCH or ISON
StringBuffer watchCommand=new StringBuffer("WATCH");
for(Iterator<IRCUserAddress> i=watchList.keySet().iterator();i.hasNext();)
{
IRCUserAddress mask = i.next();
// If still trying WATCH...
if(actualWatch.size()<serverMax)
{
String watchString=mask.toString();
if(watchString.length()+2+watchCommand.length() > 512) // +2 = " +"
{
// Time for a new command, send existing one
s.sendLine(IRCMsg.constructBytes(watchCommand.toString()));
watchCommand=new StringBuffer("WATCH");
}
watchCommand.append(" +");
watchCommand.append(watchString);
actualWatch.add(mask);
}
else
{
ison.add(mask);
}
actualNicks.put(mask.getNick().toLowerCase(),mask);
}
if(watchCommand.length()>5)
{
s.sendLine(IRCMsg.constructBytes(watchCommand.toString()));
}
if(ison.size()>0)
{
sendISON();
}
started=true;
for(Iterator<Runnable> i=backlog.iterator();i.hasNext();)
{
(i.next()).run();
}
backlog=null;
}
void addPerm(final IRCUserAddress mask)
{
if(!started)
{
backlog.add(new Runnable()
{
@Override
public void run()
{
addPerm(mask);
}
});
return;
}
// If it's in the temp list anyhow, do nothing
if(tempList.containsKey(mask)) return;
addSomewhere(mask);
}
private void addSomewhere(IRCUserAddress mask)
{
if(actualWatch.size()<serverMax)
{
actualWatch.add(mask);
s.sendLine(IRCMsg.constructBytes("WATCH +"+mask.toString()));
}
else
{
ison.add(mask);
String isonCommand="ISON "+mask.getNick();
s.sendLine(IRCMsg.constructBytes(isonCommand)); // So we get immediate notification
pendingISON.addLast(isonCommand);
}
actualNicks.put(mask.getNick().toLowerCase(),mask);
}
void removePerm(final IRCUserAddress mask)
{
if(!started)
{
backlog.add(new Runnable()
{
@Override
public void run()
{
removePerm(mask);
}
});
return;
}
// If it's in the temp list anyhow, do nothing
if(tempList.containsKey(mask)) return;
removeSomewhere(mask);
}
private void removeSomewhere(IRCUserAddress mask)
{
// Okay, let's see where it is...
if(actualWatch.remove(mask))
{
s.sendLine(IRCMsg.constructBytes("WATCH -"+mask.toString()));
}
else
{
ison.remove(mask);
}
// Get rid of it from the online list too
online.remove(mask.getNick().toLowerCase());
// Get rid from the nick list
actualNicks.remove(mask.getNick().toLowerCase());
}
void addTemp(final IRCUserAddress mask)
{
if(!started)
{
backlog.add(new Runnable()
{
@Override
public void run()
{
addTemp(mask);
}
});
return;
}
Integer i=tempList.get(mask);
if(i!=null)
{
// Already in temp list, just refcount it up
tempList.put(mask, i+1);
return;
}
// OK, add it...
tempList.put(mask, 1);
// Now, is it in the perm list?
if(watchList.containsKey(mask)) return;
// Otherwise better add it
addSomewhere(mask);
}
void removeTemp(final IRCUserAddress mask)
{
if(!started)
{
backlog.add(new Runnable()
{
@Override
public void run()
{
removeTemp(mask);
}
});
return;
}
Integer i=tempList.get(mask);
if(i==null)
{
throw new BugException("Temporary mask "+mask+" not present");
}
if(i.intValue()>1)
{
tempList.put(mask, i - 1);
return;
}
// OK, remove it...
tempList.remove(mask);
// Now, is it in the perm list?
if(watchList.containsKey(mask)) return;
// Get rid of it from WATCH/ISON
removeSomewhere(mask);
}
/** List of pending ISON commands */
private LinkedList<String> pendingISON = new LinkedList<String>();
private void sendISON()
{
if(ison.size()==0) return;
lastIsonTime=System.currentTimeMillis();
lastIsonBulk=0;
// :riga-r.ca.us.dal.net 303 quen :quen blahblah
// If we don't know reported host (why not?), assume a safe value for its length
int iHostLength=s.getReportedHost()!=null ? s.getReportedHost().length() : 100;
int prefixLength=iHostLength+s.getOurNick().length()+8;
StringBuffer isonCommand = new StringBuffer("ISON");
for(Iterator<IRCUserAddress> i=ison.iterator();i.hasNext();)
{
String nick=i.next().getNick();
if(nick.length()+1+isonCommand.length() > 510-prefixLength) // 510 for CRLF
{
pendingISON.addLast(isonCommand.toString());
s.sendLine(IRCMsg.constructBytes(isonCommand.toString()));
lastIsonBulk++;
isonCommand=new StringBuffer("ISON");
}
isonCommand.append(' ');
isonCommand.append(nick);
}
if(isonCommand.length()>4)
{
pendingISON.addLast(isonCommand.toString());
s.sendLine(IRCMsg.constructBytes(isonCommand.toString()));
lastIsonBulk++;
}
}
/** Called regularly to run timed events */
void trigger()
{
if(ison.size()==0) return;
// Send ISON every 60 seconds, plus 30 for each extra line (beyond one)
// of ISON messages
int seconds=(int)((System.currentTimeMillis()-lastIsonTime)/1000L);
if(seconds > 30 + lastIsonBulk*30)
sendISON();
}
/**
* Map of online nicks. From lowercase version of nick to IRCUserAddress.
*/
Map<String, IRCUserAddress> online = new HashMap<String, IRCUserAddress>();
boolean isKnown(String nick)
{
String lcNick=nick.toLowerCase();
boolean isKnown=online.containsKey(lcNick);
return isKnown;
}
boolean isOnline(String nick)
{
String lcNick=nick.toLowerCase();
boolean isOnline=online.get(lcNick)!=null;
return isOnline;
}
/**
* Indicates that the user has been seen online. We don't know whether
* the user is of interest to the watch list.
* @param ua User address
*/
void seenOnline(final IRCUserAddress ua)
{
if(!started)
{
backlog.add(new Runnable()
{
@Override
public void run()
{
seenOnline(ua);
}
});
return;
}
// Only send it if they are in the watch list/ISON list
String lcNick=ua.getNick().toLowerCase();
IRCUserAddress mask=actualNicks.get(lcNick);
if(mask!=null && ua.matches(mask))
markOnline(ua);
}
/**
* Indicates that the user has been seen to quit. We don't know whether
* the user is of interest to the watch list.
* @param ua User address
*/
void seenOffline(final IRCUserAddress ua)
{
if(!started)
{
backlog.add(new Runnable()
{
@Override
public void run()
{
seenOffline(ua);
}
});
return;
}
// Only send it if we previously thought they were online
String lcNick=ua.getNick().toLowerCase();
IRCUserAddress mask=actualNicks.get(lcNick);
if(mask!=null && ua.matches(mask))
markOffline(ua);
}
void markOnline(final IRCUserAddress ua)
{
if(!started)
{
backlog.add(new Runnable()
{
@Override
public void run()
{
markOnline(ua);
}
});
return;
}
// Do nothing if it was already marked online
String lcNick=ua.getNick().toLowerCase();
boolean checked=online.containsKey(lcNick);
boolean present=online.get(lcNick)!=null;
if(present) return;
// Save in map and send message
online.put(lcNick,ua);
watchMsgOwner.getDispatch().dispatchMessageHandleErrors(new OnWatchMsg(s,ua,checked),false);
}
void markOffline(final IRCUserAddress ua)
{
if(!started)
{
backlog.add(new Runnable()
{
@Override
public void run()
{
markOffline(ua);
}
});
return;
}
// Do nothing if it was already marked offline
String lcNick=ua.getNick().toLowerCase();
boolean checked=online.containsKey(lcNick);
boolean present=online.get(lcNick)!=null;
if(checked && !present) return;
// Mark offline in map and send message
online.put(lcNick,null);
watchMsgOwner.getDispatch().dispatchMessageHandleErrors(new OffWatchMsg(s,ua,checked),false);
}
boolean handleISON(String found)
{
if(pendingISON.isEmpty()) return false;
// Make map of seeking nicks LC to seeking nicks in requested case
Map<String, String> seekingNicks=new HashMap<String, String>();
String[] seekingNicksArray=pendingISON.removeFirst().substring(5).toLowerCase().split(" ");
for(int i=0;i<seekingNicksArray.length;i++)
{
seekingNicks.put(seekingNicksArray[i].toLowerCase(),seekingNicksArray[i]);
}
// Map of found nicks, similar
Map<String, String> foundNicks = new HashMap<String, String>();
String[] foundNicksArray=found.split(" ");
for(int i=0;i<foundNicksArray.length;i++)
{
foundNicks.put(foundNicksArray[i].toLowerCase(),foundNicksArray[i]);
}
// Loop through checking if present. Use the preferred case when reporting.
for(Iterator<String> i=seekingNicks.keySet().iterator();i.hasNext();)
{
String seeking = i.next();
if(foundNicks.containsKey(seeking))
markOnline(new IRCUserAddress(foundNicks.get(seeking),"",""));
else
markOffline(new IRCUserAddress(seekingNicks.get(seeking),"",""));
}
return true;
}
}
/**
* @param context Context
* @throws GeneralException Error starting up
*/
public WatchListSingleton(PluginContext context) throws GeneralException
{
this.context = context;
// Read list from preferences
Preferences p=context.getSingle(Preferences.class);
PreferencesGroup pg=p.getGroup(context.getPlugin());
PreferencesGroup[] ignoreGroups=pg.getChild(IRCPrefs.PREFGROUP_WATCH).getAnon();
for(int i=0;i<ignoreGroups.length;i++)
{
watchList.put(new IRCUserAddress(
ignoreGroups[i].get(IRCPrefs.PREF_WATCH_NICK),
ignoreGroups[i].get(IRCPrefs.PREF_WATCH_USER),
ignoreGroups[i].get(IRCPrefs.PREF_WATCH_HOST)
),ignoreGroups[i]);
}
context.registerMessageOwner(listMsgOwner);
context.registerMessageOwner(watchMsgOwner);
context.registerExtraMessageClass(OnWatchMsg.class);
context.registerExtraMessageClass(OffWatchMsg.class);
context.requestMessages(UserCommandMsg.class,this,Msg.PRIORITY_NORMAL);
context.requestMessages(UserCommandListMsg.class,this,Msg.PRIORITY_NORMAL);
context.requestMessages(ServerConnectionFinishedMsg.class,this,Msg.PRIORITY_EARLY);
context.requestMessages(ServerConnectedMsg.class,this,Msg.PRIORITY_EARLY);
context.requestMessages(ServerDisconnectedMsg.class,this,Msg.PRIORITY_EARLY);
context.requestMessages(NumericIRCMsg.class,this,Msg.PRIORITY_EARLY);
context.requestMessages(UserSourceIRCMsg.class,this,Msg.PRIORITY_FIRST);
timedEventID=TimeUtils.addTimedEvent(regularTrigger,15000,true);
}
/** Track our timed event */
private int timedEventID=-1;
/** Close the timed event so it doesn't hang onto this object */
void close()
{
if(timedEventID!=-1)
TimeUtils.cancelTimedEvent(timedEventID);
}
/** Regular trigger that runs every 15 seconds to do ISON etc */
private Runnable regularTrigger=new Runnable()
{
@Override
public void run()
{
for(Iterator<ServerInfo> i=serverInfo.values().iterator();i.hasNext();)
{
i.next().trigger();
}
timedEventID=TimeUtils.addTimedEvent(regularTrigger,15000,true);
}
};
/**
* Message: Any message from a user (because they're online now).
* @param msg Message
*/
public void msg(UserSourceIRCMsg msg)
{
// If we receive a message from a user and they're offline, that means
// they must be online now...
ServerInfo si=serverInfo.get(msg.getServer());
if(si==null) return;
if(msg instanceof QuitIRCMsg)
si.seenOffline(msg.getSourceUser());
else
si.seenOnline(msg.getSourceUser());
}
/**
* Message: WATCH numerics.
* @param msg Message
*/
public void msg(NumericIRCMsg msg)
{
ServerInfo si=serverInfo.get(msg.getServer());
if(si==null) return;
switch(msg.getNumeric())
{
case NumericIRCMsg.RPL_LOGON:
case NumericIRCMsg.RPL_NOWON:
if(msg.getParams().length>3)
{
si.markOnline(
new IRCUserAddress(msg.getParamISO(1),msg.getParamISO(2),msg.getParamISO(3)));
msg.markHandled();
}
break;
case NumericIRCMsg.RPL_LOGOFF:
case NumericIRCMsg.RPL_NOWOFF:
if(msg.getParams().length>3)
{
si.markOffline(
new IRCUserAddress(msg.getParamISO(1),msg.getParamISO(2),msg.getParamISO(3)));
msg.markHandled();
}
break;
case NumericIRCMsg.RPL_WATCHOFF:
msg.markHandled();
break;
case NumericIRCMsg.RPL_ISON:
if(msg.getParams().length==2)
{
if(si.handleISON(msg.getParamISO(1)))
msg.markHandled();
}
break;
default:
return;
}
}
/**
* Message: Connected to server.
* @param msg Message
*/
public void msg(ServerConnectedMsg msg)
{
serverInfo.put(msg.getServer(),new ServerInfo(msg.getServer()));
}
/**
* Message: Complete connection to server.
* @param msg Message
*/
public void msg(ServerConnectionFinishedMsg msg)
{
ServerInfo si=serverInfo.get(msg.getServer());
si.startup();
}
/**
* Message: Disconnected from server.
* @param msg Message
*/
public void msg(ServerDisconnectedMsg msg)
{
serverInfo.remove(msg.getServer());
}
/**
* Message: User command (notify, watch).
* @param msg Message
*/
public void msg(UserCommandMsg msg)
{
if(msg.getCommand()!=null && (msg.getCommand().equals("notify") || msg.getCommand().equals("watch")))
{
String[] params=msg.getParams().split(" ");
if(params.length!=1 || params[0].equals(""))
{
msg.error("Incorrect syntax. Use /watch +<key>mask</key> or /watch -<key>mask</key>");
}
else
{
String mask=params[0];
boolean add;
if(mask.startsWith("+"))
{
add=true;
mask=mask.substring(1);
}
else if(mask.startsWith("-"))
{
add=false;
mask=mask.substring(1);
}
else
{
add=true;
}
IRCUserAddress ua=new IRCUserAddress(mask,true);
if(ua.getNick().indexOf('*')!=-1)
{
msg.error("Incorrect syntax. /watch masks may not include wildcard in nickname portion");
}
else
{
if(add)
{
if(addMask(ua))
{
msg.getMessageDisplay().showInfo("Added to watch list: <key>"+XML.esc(ua.toString())+"</key>");
}
else
{
msg.getMessageDisplay().showError("Already in watch list: <key>"+XML.esc(ua.toString())+"</key>");
}
}
else
{
if(removeMask(ua))
{
msg.getMessageDisplay().showInfo("Removed from watch list: <key>"+XML.esc(ua.toString())+"</key>");
}
else
{
msg.getMessageDisplay().showError("Not in watch list: <key>"+XML.esc(ua.toString())+"</key>");
}
}
}
}
msg.markHandled();
}
}
/**
* Message: Listing available commands.
* @param msg Message
*/
public void msg(UserCommandListMsg msg)
{
msg.addCommand(false, "watch", UserCommandListMsg.FREQ_UNCOMMON,
"/watch <+/-mask>",
"Start or stop watching a given user mask.");
}
@Override
public synchronized boolean addMask(IRCUserAddress mask)
{
if(watchList.containsKey(mask))
return false;
Preferences p=context.getSingle(Preferences.class);
PreferencesGroup newGroup=p.getGroup(context.getPlugin()).getChild(IRCPrefs.PREFGROUP_WATCH).addAnon();
newGroup.set(IRCPrefs.PREF_WATCH_NICK,mask.getNick());
newGroup.set(IRCPrefs.PREF_WATCH_USER,mask.getUser());
newGroup.set(IRCPrefs.PREF_WATCH_HOST,mask.getHost());
watchList.put(mask,newGroup);
for(Iterator<ServerInfo> i=serverInfo.values().iterator();i.hasNext();)
{
ServerInfo si=i.next();
si.addPerm(mask);
}
listMsgOwner.getDispatch().dispatchMessage(new WatchListChangeMsg(),false);
return true;
}
@Override
public synchronized boolean removeMask(IRCUserAddress mask)
{
if(!watchList.containsKey(mask))
return false;
watchList.get(mask).remove();
watchList.remove(mask);
for(Iterator<ServerInfo> i=serverInfo.values().iterator();i.hasNext();)
{
ServerInfo si=i.next();
si.removePerm(mask);
}
listMsgOwner.getDispatch().dispatchMessage(new WatchListChangeMsg(),false);
return true;
}
@Override
public synchronized void addTemporaryMask(Server s,IRCUserAddress mask)
{
ServerInfo si=serverInfo.get(s);
if(si==null) throw new BugException("Don't know server "+s);
si.addTemp(mask);
}
@Override
public synchronized void removeTemporaryMask(Server s,IRCUserAddress mask)
{
ServerInfo si=serverInfo.get(s);
if(si==null) throw new BugException("Don't know server "+s);
si.removeTemp(mask);
}
@Override
public synchronized IRCUserAddress[] getMasks()
{
return watchList.keySet().toArray(new IRCUserAddress[watchList.keySet().size()]);
}
@Override
public synchronized boolean isKnown(Server s,String nick)
{
ServerInfo si=serverInfo.get(s);
if(si==null) throw new BugException("Don't know server "+s);
return si.isKnown(nick);
}
@Override
public synchronized boolean isOnline(Server s,String nick)
{
ServerInfo si=serverInfo.get(s);
if(si==null) throw new BugException("Don't know server "+s);
return si.isOnline(nick);
}
}