package chatty;
import chatty.util.api.usericons.UsericonManager;
import chatty.ChannelStateManager.ChannelStateListener;
import chatty.util.BotNameManager;
import chatty.util.MsgTags;
import chatty.util.StringUtil;
import chatty.util.settings.Settings;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.Timer;
import java.util.TimerTask;
import java.util.logging.Logger;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.regex.PatternSyntaxException;
/**
*
* @author tduva
*/
public class TwitchConnection {
public enum JoinError {
NOT_REGISTERED, ALREADY_JOINED, INVALID_NAME
}
private static final Logger LOGGER = Logger.getLogger(TwitchConnection.class.getName());
private final ConnectionListener listener;
private final Settings settings;
/**
* Channels that should be joined after connecting.
*/
private volatile String[] autojoin;
/**
* Channels that are open in the program (in tabs if it's more than one).
*/
private final Set<String> openChannels = Collections.synchronizedSet(new HashSet<String>());
/**
* How many times to try to reconnect
*/
private long maxReconnectionAttempts = 40;
/**
* The time in seconds between reconnection attempts. The first entry is the
* time for the first attempt, second entry for the second attempt and so
* on. The last entry is used for all further attempts.
*/
private final static int[] RECONNECTION_DELAY = new int[]{1, 5, 5, 10, 10, 60};
private volatile Timer reconnectionTimer;
/**
* The username to send to the server. This is stored to reconnect.
*/
private volatile String username;
/**
* The actual password to send to the server. This can be a token as well as
* a password. This is stored to reconnect.
*/
private volatile String password;
private volatile String server;
private volatile String serverPorts = "6667";
/**
* Holds the UserManager instance, which manages all the user objects.
*/
protected UserManager users = new UserManager();
private final IrcConnection irc;
private final TwitchCommands twitchCommands;
private final SpamProtection spamProtection;
private final ChannelStateManager channelStates = new ChannelStateManager();
private boolean newSubStuff = false;
public TwitchConnection(final ConnectionListener listener, Settings settings,
String label) {
irc = new IrcConnection(label);
this.listener = listener;
this.settings = settings;
this.twitchCommands = new TwitchCommands(this);
spamProtection = new SpamProtection();
spamProtection.setLinesPerSeconds(settings.getString("spamProtection"));
users.setCapitalizedNames(settings.getBoolean("capitalizedNames"));
users.addListener(new UserManager.UserManagerListener() {
@Override
public void userUpdated(User user) {
if (user.isOnline()) {
listener.onUserUpdated(user);
}
}
});
}
private TimerTask getReconnectionTimerTask() {
return new TimerTask() {
@Override
public void run() {
reconnect();
}
};
}
public void simulate(String data) {
irc.simulate(data);
}
public void addChannelStateListener(ChannelStateListener listener) {
channelStates.addListener(listener);
}
public ChannelState getChannelState(String channel) {
return channelStates.getState(channel);
}
public void setEmotesets(Map<Integer, String> emotesets) {
users.setEmotesets(emotesets);
}
public void setUsercolorManager(UsercolorManager m) {
users.setUsercolorManager(m);
}
public void setAddressbook(Addressbook addressbook) {
users.setAddressbook(addressbook);
}
public void setUsericonManager(UsericonManager usericonManager) {
users.setUsericonManager(usericonManager);
}
public void setBotNameManager(BotNameManager m) {
users.setBotNameManager(m);
}
public void setCustomNamesManager(CustomNames customNames) {
users.setCustomNamesManager(customNames);
}
public void setMaxReconnectionAttempts(long num) {
this.maxReconnectionAttempts = num;
}
public User getUser(String channel, String name) {
return users.getUser(channel, name);
}
public User getExistingUser(String channel, String name) {
name = StringUtil.toLowerCase(name);
return users.getUserIfExists(channel, name);
}
/**
* The username used for the last connection.
*
* @return
*/
public String getUsername() {
return username;
}
public boolean isUserlistLoaded(String channel) {
return irc.isRegistered() && irc.userlistReceived.contains(channel);
}
public Set<String> getOpenChannels() {
synchronized(openChannels) {
return new HashSet<>(openChannels);
}
}
/**
* Gets the reconnection delay based on the number of attempts.
*
* @param attempt The number of attempts
* @return The delay in seconds
*/
private int getReconnectionDelay(int attempt) {
if (attempt < 1 || attempt > RECONNECTION_DELAY.length) {
return getMaxReconnectionDelay();
}
return RECONNECTION_DELAY[attempt-1];
}
public int getState() {
return irc.getState();
}
public boolean isOffline() {
return irc.isOffline();
}
public boolean isRegistered() {
return irc.isRegistered();
}
/**
* Checks if actually joined to the given channel.
*
* @param channel
* @return
*/
public boolean onChannel(String channel) {
return onChannel(channel, false);
}
public Set<String> getJoinedChannels() {
return irc.getJoinedChannels();
}
public boolean isChannelOpen(String channel) {
return openChannels.contains(channel);
}
public void closeChannel(String channel) {
if (channel.equals(WhisperManager.WHISPER_CHANNEL)) {
return;
}
partChannel(channel);
openChannels.remove(channel);
users.clear(channel);
irc.cancelJoinAttempt(channel);
}
public void setAllOffline() {
users.setAllOffline();
}
public void partChannel(String channel) {
if (onChannel(channel)) {
irc.partChannel(channel);
}
}
/**
* Checks if actually joined to the given channel and also, if not,
* optionally outputs a message to inform the user about it.
*
* @param channel
* @param showMessage
* @return
*/
public boolean onChannel(String channel, boolean showMessage) {
boolean onChannel = irc.joinedChannels.contains(channel);
if (showMessage && !onChannel) {
if (channel == null || channel.isEmpty()) {
listener.onInfo("Not in a channel");
} else {
listener.onInfo("Not in this channel (" + channel + ")");
}
}
return onChannel;
}
/**
* Actually performs the reconnect.
*/
protected void reconnect() {
cancelReconnectionTimer();
//listener.onGlobalInfo("Attempting to reconnect.. ("+irc.connectionAttempts+"/"+maxReconnectionAttempts+")");
connect();
}
private boolean cancelReconnectionTimer() {
if (reconnectionTimer != null) {
reconnectionTimer.cancel();
reconnectionTimer = null;
return true;
}
return false;
}
/**
* This actually connects to the server. All data necessary for connecting
* should already be present at this point, however it still checks again if
* it exists.
*
* Even if connected, this will store the given data and potentially use it
* for reconnecting.
*
* @param server The server address to connect to
* @param serverPorts The server ports to connect to (comma-seperated list)
* @param username The username to use for connecting
* @param password The password
* @param autojoin The channels to join after connecting
*/
public void connect(String server, String serverPorts, String username,
String password, String[] autojoin) {
this.server = server;
this.serverPorts = serverPorts;
this.username = username;
users.setLocalUsername(username);
this.password = password;
this.autojoin = autojoin;
connect();
}
/**
* Connect to the main connection based on the current login data. Will only
* connect it not already connected/connecting.
*/
private void connect() {
if (irc.getState() <= Irc.STATE_OFFLINE) {
cancelReconnectionTimer();
irc.connect(server,serverPorts,username,password, getSecuredPorts());
} else {
listener.onConnectError("Already connected or connecting.");
}
}
private Collection<Integer> getSecuredPorts() {
List setting = settings.getList("securedPorts");
Collection<Integer> result = new HashSet<>();
for (Object value : setting) {
result.add(((Long)value).intValue());
}
return result;
}
public User getSpecialUser() {
return users.specialUser;
}
/**
* Gets the maximum reconnection delay defined.
*
* @return The delay in seconds
*/
private int getMaxReconnectionDelay() {
return RECONNECTION_DELAY[RECONNECTION_DELAY.length - 1];
}
/**
* Disconnect from the server or cancel trying to reconnect.
*
* @return true if the disconnect did something, or false if not actually
* connected
*/
public boolean disconnect() {
if (cancelReconnectionTimer()) {
listener.onGlobalInfo("Canceled reconnecting");
irc.setState(Irc.STATE_OFFLINE);
irc.connectionAttempts = 0;
}
boolean success = irc.disconnect();
return success;
}
public void quit() {
irc.disconnect();
}
public String getConnectionInfo() {
String regular = irc.getConnectionInfo();
if (regular == null) {
return "Not connected.";
}
return "Connected to: "+regular;
}
public boolean autoRequestModsEnabled() {
return settings.getBoolean("autoRequestMods");
}
public User localUserJoined(String channel) {
return userJoined(channel, username);
}
public User getLocalUser(String channel) {
return users.getUser(channel, username);
}
public void sendRaw(String text) {
irc.send(text);
}
public boolean command(String channel, String command, String parameters,
String msgId) {
return twitchCommands.command(channel, msgId, command, parameters);
}
public void sendCommandMessage(String channel, String message, String echo) {
sendCommandMessage(channel, message, echo, MsgTags.EMPTY);
}
/**
* Send a spam protected command to a channel, with the given echo message
* that will be displayed to the user.
*
* This doesn't check if you're actually on the channel.
*
* @param channel The channel to send the message to
* @param message The message to send (e.g. a moderation command)
* @param echo The message to display to the user
* @param tags
*/
public void sendCommandMessage(String channel, String message, String echo,
MsgTags tags) {
if (sendSpamProtectedMessage(channel, message, false, tags)) {
listener.onInfo(channel, echo);
} else {
listener.onInfo(channel, "# Command not sent to prevent ban: " + message);
}
}
public boolean sendSpamProtectedMessage(String channel, String message, boolean action) {
return sendSpamProtectedMessage(channel, message, action, MsgTags.EMPTY);
}
/**
* Tries to send a spam protected message, which will either be send or not,
* depending on the status of the spam protection.
*
* <p>This doesn't check if you're actually on the channel.</p>
*
* @param channel The channel to send the message to
* @param message The message to send
* @param action
* @return true if the message was send, false otherwise
*/
public boolean sendSpamProtectedMessage(String channel, String message,
boolean action, MsgTags tags) {
if (!spamProtection.check()) {
return false;
} else {
spamProtection.increase();
if (action) {
irc.sendActionMessage(channel, message);
} else {
irc.sendMessage(channel, message, tags);
}
return true;
}
}
public int getNumJoinedChannels() {
return irc.joinedChannels.size();
}
public void join(String channel) {
irc.joinChannel(channel);
}
/**
* Joins the channel with the given name, but only if the channel name
* is deemed valid, it's possible to join channels at this point and we are
* not already on the channel.
*
* @param channel The name of the channel, with or without leading '#'.
*/
public void joinChannel(String channel) {
Set<String> channels = new HashSet<>();
channels.add(channel);
joinChannels(channels);
}
/**
* Join a rename of channels. Sorts out invalid channels and outputs an error
* message, then joins the valid channels.
*
* @param channels Set of channelnames (valid/invalid, leading # or not).
*/
public void joinChannels(Set<String> channels) {
Set<String> valid = new LinkedHashSet<>();
Set<String> invalid = new LinkedHashSet<>();
for (String channel : channels) {
String checkedChannel = Helper.toValidChannel(channel);
if (checkedChannel == null) {
invalid.add(channel);
} else {
valid.add(checkedChannel);
}
}
for (String channel : invalid) {
listener.onJoinError(channels, channel, JoinError.INVALID_NAME);
}
joinValidChannels(valid);
}
/**
* Joins the valid channels. If offline, opens the connect dialog with the
* valid channels already entered.
*
* @param valid A Set of valid channels (valid names, with leading #).
*/
private void joinValidChannels(Set<String> valid) {
if (valid.isEmpty()) {
return;
} else if (!irc.isRegistered()) {
listener.onJoinError(valid, null, JoinError.NOT_REGISTERED);
} else {
for (String channel : valid) {
if (onChannel(channel)) {
listener.onJoinError(valid, channel, JoinError.ALREADY_JOINED);
} else {
join(channel);
}
}
}
}
/**
* IRC Connection which handles the messages (manages users, special
* messages etc.) and redirects them to the listener accordingly.
*/
private class IrcConnection extends Irc {
/**
* How many times was tried to connect. Reset when the connection is
* fully established (registered).
*/
private int connectionAttempts = 0;
/**
* At what time this connection last attempted to connect.
*/
private long lastConnectionAttempt;
private final JoinChecker joinChecker = new JoinChecker(this);
/**
* Channels that this connection has joined. This is per connection, so
* the main and secondary connection have different data here.
*/
private final Set<String> joinedChannels = Collections.synchronizedSet(
new HashSet<String>());
/**
* The prefix used for debug messages, so it can be determined which
* connection it is from.
*/
private final String idPrefix;
/**
* This only applies to irc2. This is reset on every new connection.
* It's set to true once either a JOIN or a userlist from any channel
* is received. It roughly indicates that the connection has probably
* started to receive users.
*/
private Set<String> userlistReceived = Collections.synchronizedSet(
new HashSet<String>());
public IrcConnection(String id) {
super(id);
this.idPrefix= "["+id+"] ";
}
public int getLastConnectionAttemptAgo() {
return (int)((System.currentTimeMillis() - lastConnectionAttempt) / 1000);
}
public Set<String> getJoinedChannels() {
synchronized (joinedChannels) {
return new HashSet<>(joinedChannels);
}
}
public boolean onChannel(String channel) {
return joinedChannels.contains(channel);
}
public boolean primaryOnChannel(String channel) {
return irc.onChannel(channel);
}
@Override
void onUserlist(String channel, String[] nicknames) {
channel = channel.toLowerCase();
if (isChannelOpen(channel)) {
/**
* Don't clear userlist just yet if only local name is in the
* userlist, which may mean that the actual userlist is send
* using JOINs later.
*/
if (nicknames.length == 1
&& nicknames[0].equalsIgnoreCase(username)) {
localUserJoined(channel);
return;
}
/**
* Clear current userlist before adding the new userlist if this
* is the first time receiving the userlist this connection.
*/
if (!userlistReceived.contains(channel)) {
clearUserlist(channel);
}
userlistReceived.add(channel);
for (String nick : nicknames) {
userJoined(channel, nick);
}
}
}
@Override
public void debug(String line) {
LOGGER.info(idPrefix+line);
}
@Override
void onConnectionAttempt(String server, int port, boolean secured) {
connectionAttempts++;
lastConnectionAttempt = System.currentTimeMillis();
if (this != irc) {
return;
}
if (server != null) {
listener.onGlobalInfo("Trying to connect to " + server + ":" + port+(secured ? " (secured)" : ""));
} else {
listener.onGlobalInfo("Failed to connect (server or port invalid)");
}
}
@Override
void onConnect() {
if (this == irc) {
send("CAP REQ :twitch.tv/tags");
send("CAP REQ :twitch.tv/commands");
if (settings.getBoolean("membershipEnabled")) {
send("CAP REQ :twitch.tv/membership");
}
send("CAP END");
//send("TWITCHCLIENT 4");
}
userlistReceived.clear();
}
@Override
void onRegistered() {
connectionAttempts = 1;
if (this != irc) {
return;
}
if (autojoin != null) {
for (String channel : autojoin) {
joinChannel(channel);
}
/**
* Only use autojoin once, to prevent it from being used on
* reconnect (open channels should be used for that).
*/
autojoin = null;
} else {
joinChannels(getOpenChannels());
}
listener.onRegistered();
}
@Override
void onDisconnect(int reason, String reasonMessage) {
joinedChannels.clear();
joinChecker.cancelAll();
if (this == irc) {
channelStates.reset();
twitchCommands.clearModsAlreadyRequested(null);
listener.onGlobalInfo("Disconnected" + Helper.makeDisconnectReason(reason, reasonMessage));
if (reason != Irc.REQUESTED_DISCONNECT) {
startReconnectTimer(reason);
} else {
connectionAttempts = 0;
}
listener.onDisconnect(reason, reasonMessage);
}
}
private void startReconnectTimer(int reason) {
if (reconnectionTimer == null) {
if (connectionAttempts > maxReconnectionAttempts
&& maxReconnectionAttempts > -1) {
listener.onGlobalInfo("Gave up reconnecting. :(");
} else {
int delay = getReconnectionDelay(connectionAttempts);
listener.onGlobalInfo(String.format(
"Attempting to reconnect in %s seconds.. (%s/%s)",
delay,
irc.connectionAttempts,
maxReconnectionAttempts < 0 ? "∞" : maxReconnectionAttempts));
setState(Irc.STATE_RECONNECTING);
reconnectionTimer = new Timer();
reconnectionTimer.schedule(getReconnectionTimerTask(), delay * 1000);
}
}
}
@Override
void onJoinAttempt(String channel) {
channel = channel.toLowerCase();
joinChecker.joinAttempt(channel);
if (this == irc) {
listener.onJoinAttempt(channel);
openChannels.add(channel);
}
}
@Override
void onJoin(String channel, String nick, String prefix) {
channel = channel.toLowerCase();
if (nick.equalsIgnoreCase(username)) {
/**
* Local user has joined a channel.
*/
joinChecker.cancel(channel);
debug("JOINED: " + channel);
User user = userJoined(channel, nick);
if (this == irc && !onChannel(channel)) {
listener.onChannelJoined(user);
}
joinedChannels.add(channel);
} else {
/**
* Another user has joined a channel we are currently in.
*/
if (isChannelOpen(channel)) {
if (!userlistReceived.contains(channel)) {
clearUserlist(channel);
// Add local user again, must be on this channel but
// may not be in the batch of joins again
localUserJoined(channel);
}
User user = userJoined(channel, nick);
listener.onJoin(user);
userlistReceived.add(channel);
}
}
}
private void clearUserlist(String channel) {
//System.out.println("userlist cleared"+channel);
users.setAllOffline(channel);
listener.onUserlistCleared(channel);
}
public void cancelJoinAttempt(String channel) {
joinChecker.cancel(channel);
}
@Override
void onPart(String channel, String nick, String prefix, String message) {
channel = channel.toLowerCase();
if (nick.isEmpty()) {
return;
}
if (!onChannel(channel)) {
return;
}
if (nick.equalsIgnoreCase(username)) {
/**
* Local User Leaving Channel
*/
joinChecker.cancel(channel);
if (this == irc) {
userOffline(channel, nick);
}
joinedChannels.remove(channel);
if (this == irc) {
twitchCommands.clearModsAlreadyRequested(channel);
// Remove users for this channel, clearing the userlist in the
// GUI shouldn't be necessary if this channel is closed since
// the GUI userlist is removed as well.
users.clear(channel);
listener.onChannelLeft(channel);
channelStates.reset(channel);
}
// Leaving the channel on the userlist connection means
// the userlist can no longer be considered as received for
// this channel.
userlistReceived.remove(channel);
debug("PARTED: "+channel);
} else {
if (isChannelOpen(channel)) {
User user = userOffline(channel, nick);
listener.onPart(user);
}
}
}
@Override
void onModeChange(String channel, String nick, boolean modeAdded, String mode, String prefix) {
channel = channel.toLowerCase();
if (!onChannel(channel)) {
return;
}
User user = users.getUser(channel, nick);
if (modeAdded) {
user.setMode(mode);
if (mode.equals("o")) {
if (this == irc) {
listener.onMod(user);
}
if (!isUserlistLoaded(channel)) {
userJoined(user);
}
}
} else {
user.setMode("");
if (mode.equals("o")) {
if (this == irc) {
listener.onUnmod(user);
}
}
}
// Notify userlist to update the changed user, but only if he is still
// in the channel
if (user.isOnline()) {
listener.onUserUpdated(user);
}
}
private void updateUserFromTags(User user, MsgTags tags) {
if (tags.isEmpty()) {
return;
}
/**
* Any and all tag values may be null, so account for that when
* checking against them.
*/
// Whether anything in the user changed to warrant an update
boolean changed = false;
Map<String, String> badges = Helper.parseBadges(tags.get("badges"));
if (user.setTwitchBadges(badges)) {
changed = true;
}
if (settings.getBoolean("ircv3CapitalizedNames")) {
if (user.setDisplayNick(StringUtil.trim(tags.get("display-name")))) {
changed = true;
}
}
// Update color
String color = tags.get("color");
if (color != null && !color.isEmpty()) {
user.setColor(color);
}
// Update user status
boolean turbo = tags.isTrue("turbo") || badges.containsKey("turbo") || badges.containsKey("premium");
if (user.setTurbo(turbo)) {
changed = true;
}
if (user.setSubscriber(tags.isTrue("subscriber"))) {
changed = true;
}
// Temporarily check both for containing a value as Twitch is
// changing it
String userType = tags.get("user-type");
if (user.setModerator("mod".equals(userType))) {
changed = true;
}
if (user.setStaff("staff".equals(userType))) {
changed = true;
}
if (user.setAdmin("admin".equals(userType))) {
changed = true;
}
if (user.setGlobalMod("global_mod".equals(userType))) {
changed = true;
}
user.setId(tags.get("user-id"));
if (changed && user != users.specialUser) {
listener.onUserUpdated(user);
}
}
@Override
void onChannelMessage(String channel, String nick, String from, String text,
MsgTags tags, boolean action) {
channel = channel.toLowerCase();
if (this != irc) {
return;
}
if (nick.isEmpty()) {
return;
}
if (onChannel(channel)) {
if (settings.getBoolean("twitchnotifyAsInfo") && nick.equals("twitchnotify")) {
if (!newSubStuff) {
listener.onSubscriberNotification(channel, users.dummyUser, text, null, 1, null);
}
} else {
User user = userJoined(channel, nick);
updateUserFromTags(user, tags);
String emotesTag = tags.get("emotes");
String id = tags.get("id");
int bits = tags.getInteger("bits", 0);
listener.onChannelMessage(user, text, action, emotesTag, id, bits);
}
}
}
@Override
void onNotice(String nick, String from, String text) {
if (this != irc) {
return;
}
// Should only be from the server for now
listener.onNotice(text);
}
@Override
void onNotice(String channel, String text, MsgTags tags) {
channel = channel.toLowerCase();
if (this != irc) {
return;
}
if (tags.isValue("msg-id", "whisper_invalid_login")) {
listener.onInfo(text);
} else if (onChannel(channel)) {
infoMessage(channel, text);
} else {
listener.onInfo(String.format("[Info/%s] %s", channel, text));
}
}
@Override
void onUsernotice(String channel, String message, MsgTags tags) {
if (tags.isEmpty()) {
return;
}
if (!onChannel(channel)) {
return;
}
String login = tags.get("login");
String text = tags.get("system-msg");
String emotes = tags.get("emotes");
int months = tags.getInteger("msg-param-months", -1);
if (StringUtil.isNullOrEmpty(login, text)) {
return;
}
User user = userJoined(channel, login);
updateUserFromTags(user, tags);
if (tags.isValue("msg-id", "sub")) {
newSubStuff = true;
}
if (tags.isValue("msg-id", "resub") || tags.isValue("msg-id", "sub")) {
listener.onSubscriberNotification(channel, user, text, message, months, emotes);
} else {
// Not sure about this, there may be some weird messages
//listener.onInfo(channel, text);
//listener.onChannelMessage(user, message, false, emotes, null, 0);
}
}
@Override
void onQueryMessage(String nick, String from, String text) {
if (this != irc) {
return;
}
if (nick.startsWith("*")) {
listener.onSpecialMessage(nick, text);
}
if (nick.equals("jtv")) {
listener.onInfo("[Info] "+text);
}
}
/**
* Any kind of info message. This can be either from jtv (legacy) or the
* new NOTICE messages to the channel.
*
* @param channel
* @param text
*/
private void infoMessage(String channel, String text) {
if (text.startsWith("The moderators of")) {
parseModeratorsList(text, channel);
} else {
listener.onInfo(channel, "[Info] " + text);
}
}
/**
* Counts the moderators in the /mods response and outputs the count.
*
* @param text The mesasge from jtv containing the comma-seperated
* moderator list.
* @param channel The channel the moderators list was received on, or
* {@literal null} if the channel is unknown
*/
private void parseModeratorsList(String text, String channel) {
// Get list of users from message
List<String> modsList = TwitchCommands.parseModsList(text);
users.modsListReceived(channel, modsList);
/**
* Output messages only if either:
*
* a) No /mod response is currently expected to be silent Or b) The
* channel was detected (TC3 or through guessing) and is not on the
* list of channels with a /mod response expected to be silent
*
* a) has to be checked first, because b) might remove the channel,
* so a) might be true even if it shouldn't be
*/
if (!twitchCommands.waitingForModsSilent()
|| (channel != null && !twitchCommands.removeModsSilent(channel))) {
listener.onInfo(channel, "[Info] " + text);
// Output appropriate message
if (modsList.size() > 0) {
listener.onInfo(channel, "There are " + modsList.size() + " mods for this channel.");
} else {
listener.onInfo(channel, "There are no mods for this channel.");
}
} else {
debug("Silent mods list (" + channel + ")");
}
}
/**
* Inform the user that a channel was cleared. If {@literal channel} is
* not {@literal null}, then it is output to that channel. Otherwise it
* is output to the current channel.
*
* @param channel The channel that was cleared, or {@literal null} if
* the channel is unknown
*/
private void channelCleared(String channel) {
listener.onChannelCleared(channel);
}
@Override
void onWhoResponse(String channel, String nickname) {
// Not working on Twitch Chat anyway
}
@Override
protected void setState(int state) {
super.setState(state);
listener.onConnectionStateChanged(state);
}
/**
* Checks if the given channel should be open.
*
* @param channel The channel name
* @return
*/
public boolean isChannelOpen(String channel) {
return openChannels.contains(channel);
}
@Override
public void raw(String text) {
listener.onRawReceived(idPrefix+text);
}
@Override
public void sent(String text) {
if (text.startsWith("PASS")) {
listener.onRawSent(idPrefix+"PASS <password>");
} else {
listener.onRawSent(idPrefix+text);
}
}
@Override
public void onUserstate(String channel, MsgTags tags) {
channel = channel.toLowerCase();
if (onChannel(channel)) {
updateUserstate(channel, tags);
}
}
@Override
public void onGlobalUserstate(MsgTags tags) {
updateUserstate(null, tags);
}
private void updateUserstate(String channel, MsgTags tags) {
String emotesets = tags.get("emote-sets");
if (channel != null) {
/**
* Update state for the local user in the given channel, also
* assuming the user is now in that channel and thus adding the
* user if necessary.
*/
User user = localUserJoined(channel);
updateUserFromTags(user, tags);
user.setEmoteSets(emotesets);
} else {
/**
* Update all existing users with the local name, assuming that
* all the state is global if no channel is given.
*/
for (User user : users.getUsersByName(username)) {
updateUserFromTags(user, tags);
user.setEmoteSets(emotesets);
}
}
/**
* Update special user which can be used to initialize newly created
* local users on other channels. This may be necessary when some
* info is only being send in the GLOBALUSERSTATE command, which may
* not be send after every join or message.
*
* This may be updated with local and global info, however only the
* global info is used to initialize newly created local users.
*
* The special user is also used to get the emotesets the local user
* has access to in other areas of the program like the Emotes
* Dialog.
*/
users.specialUser.setEmoteSets(emotesets);
listener.onSpecialUserUpdated();
updateUserFromTags(users.specialUser, tags);
}
@Override
public void onClearChat(MsgTags tags, String channel,
String nick) {
channel = channel.toLowerCase();
if (nick != null) {
// A single user was timed out/banned
User user = users.getUserIfExists(channel, nick);
if (user != null) {
long duration = tags.getLong("ban-duration", -1);
String reason = tags.get("ban-reason", "");
String targetMsgId = tags.get("target-msg-id", null);
if (isChannelOpen(user.getChannel())) {
listener.onBan(user, duration, reason, targetMsgId);
}
}
} else {
// No nick specified means the channel is cleared
channelCleared(channel);
}
}
@Override
public void onChannelCommand(MsgTags tags, String nick,
String channel, String command, String trailing) {
channel = channel.toLowerCase();
if (command.equals("HOSTTARGET")) {
String[] parameters = trailing.split(" ");
if (parameters.length == 2) {
String target = parameters[0];
if (target.equals("-")) {
listener.onHost(channel, null);
channelStates.setHosting(channel, null);
} else {
listener.onHost(channel, target);
channelStates.setHosting(channel, target);
}
}
} else if (command.equals("ROOMSTATE")) {
if (!tags.isEmpty()) {
/**
* ROOMSTATE doesn't always have to contain all states, so
* only work with those that are actually there (otherwise
* they may be inadvertently recognized as false).
*/
if (tags.containsKey("r9k")) {
channelStates.setR9kMode(channel, tags.isTrue("r9k"));
}
if (tags.containsKey("emote-only")) {
channelStates.setEmoteOnly(channel, tags.isTrue("emote-only"));
}
if (tags.containsKey("subs-only")) {
channelStates.setSubmode(channel, tags.isTrue("subs-only"));
}
if (tags.containsKey("slow")) {
channelStates.setSlowmode(channel, tags.get("slow"));
}
if (tags.containsKey("broadcaster-lang")) {
channelStates.setLang(channel, tags.get("broadcaster-lang"));
}
if (tags.containsKey("followers-only")) {
channelStates.setFollowersOnly(channel, tags.get("followers-only"));
}
if (!tags.isEmpty("room-id")) {
listener.onRoomId(channel, tags.get("room-id"));
}
}
} else if (command.equals("SERVERCHANGE")) {
listener.onInfo(channel, "*** You may be on the wrong server "
+ "for this channel. Enter /fixserver to connect to the "
+ "correct server (which may cause other channels to not "
+ "work anymore, because Chatty only supports one main "
+ "connection to a single server). ***");
}
}
@Override
public void onCommand(String nick, String command, String parameter, String text, MsgTags tags) {
if (nick.isEmpty()) {
return;
}
if (command.equals("WHISPER")) {
User user = userJoined(WhisperManager.WHISPER_CHANNEL, nick);
updateUserFromTags(user, tags);
listener.onWhisper(user, text, tags.get("emotes"));
}
}
}
/**
* Sets a user as offline, removing the user from the userlist, the user
* won't be deleted though, for possible further reference
*
* @param channel
* @param name
* @return
*/
public User userOffline(String channel, String name) {
User user = users.getUser(channel, name);
if (user != null) {
user.setOnline(false);
listener.onUserRemoved(user);
}
return user;
}
/**
* Sets a user as online, add the user to the userlist if not already
* online.
*
* @param channel The channel the user joined
* @param name The name of the user
* @return The User
*/
public User userJoined(String channel, String name) {
User user = users.getUser(channel, name);
return userJoined(user);
}
public User userJoined(User user) {
if (user.setOnline(true)) {
String channel = user.getChannel();
if (channel.substring(1).equals(user.getName())) {
user.setBroadcaster(true);
}
listener.onUserAdded(user);
}
return user;
}
public void info(String channel, String message) {
listener.onInfo(channel, message);
}
public void info(String message) {
listener.onInfo(message);
}
public interface ConnectionListener {
void onJoinAttempt(String channel);
void onChannelJoined(User channel);
void onChannelLeft(String channel);
void onJoin(User user);
void onPart(User user);
void onUserAdded(User user);
void onUserRemoved(User user);
void onUserlistCleared(String channel);
void onUserUpdated(User user);
void onChannelMessage(User user, String message, boolean action, String emotes, String id, int bits);
void onWhisper(User user, String message, String emotes);
void onNotice(String message);
/**
* An info message to a specific channel, usually intended to be
* directly output to the user.
*
* <p>The channel should not be null. If no channel is associated, use
* {@link onInfo(String) onInfo(infoMessage)} instead.</p>
*
* @param channel The channel the info message belongs to
* @param infoMessage The info message
*/
void onInfo(String channel, String infoMessage);
/**
* An info message, usually intended to be directly output to the user.
*
* <p>Since no channel is associated, this is likely to be output to the
* currently active channel/tab.</p>
*
* @param infoMessage The info message
*/
void onInfo(String infoMessage);
void onGlobalInfo(String message);
void onBan(User user, long length, String reason, String targetMsgId);
void onRegistered();
void onDisconnect(int reason, String reasonMessage);
void onMod(User user);
void onUnmod(User user);
void onConnectionStateChanged(int state);
void onSpecialUserUpdated();
void onConnectError(String message);
void onJoinError(Set<String> toJoin, String errorChannel, JoinError error);
void onRawReceived(String text);
void onRawSent(String text);
void onHost(String channel, String target);
void onChannelCleared(String channel);
/**
* A notification in chat for a new subscriber or resub.
*
* @param channel The channel (never null)
* @param user The User object (may be dummy user object with empty
* name, but never null)
* @param text The notification text (never null or empty)
* @param message The attached message (may be null or empty)
* @param months The number of subscribed months (may be -1 if invalid)
* @param emotes The emotes tag, yet to be parsed (may be null)
*/
void onSubscriberNotification(String channel, User user, String text, String message, int months, String emotes);
void onSpecialMessage(String name, String message);
void onRoomId(String channel, String id);
}
}