diff --git a/src/chatty/ErrorHandler.java b/src/chatty/ErrorHandler.java index 3a28b745f..77547b541 100644 --- a/src/chatty/ErrorHandler.java +++ b/src/chatty/ErrorHandler.java @@ -1,6 +1,7 @@ package chatty; +import chatty.util.TimedCounter; import java.io.PrintWriter; import java.io.StringWriter; import java.lang.Thread.UncaughtExceptionHandler; @@ -15,9 +16,15 @@ public class ErrorHandler implements UncaughtExceptionHandler { private final static Logger LOGGER = Logger.getLogger(ErrorHandler.class.getName()); + private final TimedCounter counter = new TimedCounter(60*1000, 0); @Override public void uncaughtException(Thread t, Throwable e) { + counter.increase(); + if (counter.getCount(false) > 1000) { + LOGGER.warning("Over 1000 errors in a minute, exiting application."); + System.exit(1); + } if (e == null && t != null) { LOGGER.severe("Unknown exception in thread "+t.toString()); return; diff --git a/src/chatty/Irc.java b/src/chatty/Irc.java index c3442a01f..4f4749a80 100644 --- a/src/chatty/Irc.java +++ b/src/chatty/Irc.java @@ -397,6 +397,11 @@ private void receivedCommand(String prefix, String command, LOGGER.info("Unknown info message: "+trailing); } } + if (command.equals("USERNOTICE")) { + if (parameters.length == 1 && parameters[0].startsWith("#")) { + onUsernotice(parameters[0], trailing, tags); + } + } if (command.equals("JOIN")) { if (trailing.isEmpty() && parameters.length > 0) { onJoin(parameters[0], nick, prefix); @@ -687,4 +692,6 @@ void onClearChat(Map tags, String channel, String name) { } void onChannelCommand(Map tags, String nick, String channel, String command, String trailing) { } void onCommand(String nick, String command, String parameter, String text, Map tags) { } + + void onUsernotice(String channel, String message, Map tags) { } } diff --git a/src/chatty/Logging.java b/src/chatty/Logging.java index 46efb65e1..4e373eb0b 100644 --- a/src/chatty/Logging.java +++ b/src/chatty/Logging.java @@ -109,12 +109,12 @@ static class TextFormatter extends Formatter { @Override public String format(LogRecord record) { - return String.format("[%1$tT %5$s] %2$s [%3$s/%4$s]\n", + return String.format("[%1$tF %1$tT %5$s] %2$s [%3$s/%4$s]\n", new Date(record.getMillis()), record.getMessage(), record.getSourceClassName(), record.getSourceMethodName(), - record.getLevel().getLocalizedName()); + record.getLevel().getName()); } } diff --git a/src/chatty/SettingsManager.java b/src/chatty/SettingsManager.java index 91f1a9d2f..77e726390 100644 --- a/src/chatty/SettingsManager.java +++ b/src/chatty/SettingsManager.java @@ -153,6 +153,8 @@ void defineSettings() { settings.setFile("token_subs", loginFile); settings.addBoolean("token_chat", false); settings.setFile("token_chat", loginFile); + settings.addBoolean("token_follow", false); + settings.setFile("token_follow", loginFile); //================= // Appearance / GUI diff --git a/src/chatty/TwitchClient.java b/src/chatty/TwitchClient.java index 350f45814..1be18c464 100644 --- a/src/chatty/TwitchClient.java +++ b/src/chatty/TwitchClient.java @@ -924,6 +924,12 @@ else if (command.equals("clearemotecache")) { //------ // Other //------ + else if (command.equals("follow")) { + commandFollow(channel, parameter); + } + else if (command.equals("unfollow")) { + commandUnfollow(channel, parameter); + } else if (command.equals("addstreamhighlight")) { commandAddStreamHighlight(channel, parameter); } @@ -1339,6 +1345,47 @@ private void commandCustomCompletion(String parameter) { } } + /** + * Follows the stream given in the parameter, or the channel if no parameter + * is given. + * + * @param channel + * @param parameter + */ + public void commandFollow(String channel, String parameter) { + String user = settings.getString("username"); + String target = Helper.toStream(channel); + if (parameter != null && !parameter.isEmpty()) { + target = Helper.toStream(parameter.trim()); + } + if (!Helper.validateStream(target)) { + g.printSystem("No valid channel to follow."); + return; + } + if (!Helper.validateStream(user)) { + g.printSystem("No valid username."); + return; + } + api.followChannel(user, target); + } + + public void commandUnfollow(String channel, String parameter) { + String user = settings.getString("username"); + String target = Helper.toStream(channel); + if (parameter != null && !parameter.isEmpty()) { + target = Helper.toStream(parameter.trim()); + } + if (!Helper.validateStream(target)) { + g.printSystem("No valid channel to unfollow."); + return; + } + if (!Helper.validateStream(user)) { + g.printSystem("No valid username."); + return; + } + api.unfollowChannel(user, target); + } + public void commandAddStreamHighlight(String channel, String parameter) { g.printLine(channel, streamHighlights.addHighlight(channel, parameter)); } @@ -1605,6 +1652,11 @@ public void receivedServer(String channel, String server) { g.printLine(channel, "An error occured requesting server info."); } } + + @Override + public void followResult(String message) { + g.printSystem(message); + } } private void checkToken() { diff --git a/src/chatty/TwitchConnection.java b/src/chatty/TwitchConnection.java index 1c4552b77..dd1aac73a 100644 --- a/src/chatty/TwitchConnection.java +++ b/src/chatty/TwitchConnection.java @@ -956,6 +956,23 @@ void onNotice(String channel, String text, Map tags) { listener.onInfo(String.format("[Info/%s] %s", channel, text)); } } + + @Override + void onUsernotice(String channel, String text, Map tags) { + if ("resub".equals(tags.get("msg-id"))) { + if (text.isEmpty()) { + listener.onInfo(channel, "[Notification] "+tags.get("system-msg")); + } else { + listener.onInfo(channel, "[Notification] "+tags.get("system-msg")+" ["+text+"]"); + } + try { + int months = Integer.parseInt(tags.get("msg-param-months")); + listener.onSubscriberNotification(channel, tags.get("login"), months); + } catch (Exception ex) { + // Do nothing + } + } + } @Override void onQueryMessage(String nick, String from, String text) { diff --git a/src/chatty/gui/MainGui.java b/src/chatty/gui/MainGui.java index f8e6c5292..b85f6f53c 100644 --- a/src/chatty/gui/MainGui.java +++ b/src/chatty/gui/MainGui.java @@ -1642,6 +1642,14 @@ private void streamStuff(String cmd, Collection streams) { } else { printLine("Can't host more than one channel."); } + } else if (cmd.equals("follow")) { + for (String stream : streams) { + client.commandFollow(null, stream); + } + } else if (cmd.equals("unfollow")) { + for (String stream : streams) { + client.commandUnfollow(null, stream); + } } else if (cmd.equals("copy") && !streams.isEmpty()) { MiscUtil.copyToClipboard(StringUtil.join(streams, ", ")); } @@ -3391,6 +3399,7 @@ private void setTokenScopes(TokenInfo info) { client.settings.setBoolean("token_commercials", info.channel_commercials); client.settings.setBoolean("token_user", info.user_read); client.settings.setBoolean("token_subs", info.channel_subscriptions); + client.settings.setBoolean("token_follow", info.user_follows_edit); } else { resetTokenScopes(); @@ -3407,7 +3416,8 @@ private void updateTokenScopes() { boolean editor = client.settings.getBoolean("token_editor"); boolean user = client.settings.getBoolean("token_user"); boolean subscriptions = client.settings.getBoolean("token_subs"); - tokenDialog.updateAccess(chat, editor, commercials, user, subscriptions); + boolean follow = client.settings.getBoolean("token_follow"); + tokenDialog.updateAccess(chat, editor, commercials, user, subscriptions, follow); adminDialog.updateAccess(editor, commercials); } @@ -3417,6 +3427,7 @@ private void resetTokenScopes() { client.settings.setBoolean("token_editor", false); client.settings.setBoolean("token_user", false); client.settings.setBoolean("token_subs", false); + client.settings.setBoolean("token_follow", false); } public void showTokenWarning() { diff --git a/src/chatty/gui/components/TokenDialog.java b/src/chatty/gui/components/TokenDialog.java index fd0e3e7b7..aee19e80b 100644 --- a/src/chatty/gui/components/TokenDialog.java +++ b/src/chatty/gui/components/TokenDialog.java @@ -136,7 +136,7 @@ public void update(String username, String currentToken) { * @param subs */ public void updateAccess(boolean chat, boolean editor, boolean commercial, - boolean user, boolean subs) { + boolean user, boolean subs, boolean follow) { boolean empty = currentUsername.isEmpty() || currentToken.isEmpty(); access.setVisible(!empty); accessLabel.setVisible(!empty); @@ -146,7 +146,8 @@ public void updateAccess(boolean chat, boolean editor, boolean commercial, b.append(accessStatusImage(user)).append(" Read user info
"); b.append(accessStatusImage(editor)).append(" Editor access
"); b.append(accessStatusImage(commercial)).append(" Run commercials
"); - b.append(accessStatusImage(subs)).append(" Show subscribers"); + b.append(accessStatusImage(subs)).append(" Show subscribers
"); + b.append(accessStatusImage(follow)).append(" Follow channels"); access.setText(b.toString()); update(); diff --git a/src/chatty/gui/components/TokenGetDialog.java b/src/chatty/gui/components/TokenGetDialog.java index 1212ad849..00077e6ac 100644 --- a/src/chatty/gui/components/TokenGetDialog.java +++ b/src/chatty/gui/components/TokenGetDialog.java @@ -42,7 +42,8 @@ public class TokenGetDialog extends JDialog implements ItemListener, ActionListe private final JCheckBox includeReadUserAccess = new JCheckBox("Read user info (followed streams)"); private final JCheckBox includeEditorAccess = new JCheckBox("Editor access (edit stream title/game)"); private final JCheckBox includeCommercialAccess = new JCheckBox("Allow running ads"); - private final JCheckBox includeShowSubsAccess = new JCheckBox("Show your subscribers"); + private final JCheckBox includeShowSubsAccess = new JCheckBox("View your subscribers"); + private final JCheckBox includeFollowAccess = new JCheckBox("Follow channels"); private String currentUrl = TwitchClient.REQUEST_TOKEN_URL; @@ -64,6 +65,7 @@ public TokenGetDialog(MainGui owner) { includeEditorAccess.setSelected(true); includeCommercialAccess.setSelected(true); includeShowSubsAccess.setSelected(true); + includeFollowAccess.setSelected(true); // Options gbc = makeGridBagConstraints(0, 1, 2, 1, GridBagConstraints.WEST); @@ -71,35 +73,43 @@ public TokenGetDialog(MainGui owner) { includeReadUserAccess.setToolTipText("To get notified when streams you " + "follow go online."); add(includeReadUserAccess, gbc); + gbc = makeGridBagConstraints(0, 2, 2, 1, GridBagConstraints.WEST); gbc.insets = new Insets(0,5,0,5); includeEditorAccess.setToolTipText("To be able to edit your channel's title and game."); add(includeEditorAccess,gbc); + gbc = makeGridBagConstraints(0,3,2,1,GridBagConstraints.WEST); gbc.insets = new Insets(0,5,0,5); includeCommercialAccess.setToolTipText("To be able to run commercials on your stream."); add(includeCommercialAccess,gbc); + gbc = makeGridBagConstraints(0,4,2,1,GridBagConstraints.WEST); - gbc.insets = new Insets(0,5,5,5); + gbc.insets = new Insets(0,5,0,5); includeShowSubsAccess.setToolTipText("To be able to show the list of your subscribers."); add(includeShowSubsAccess,gbc); + gbc = makeGridBagConstraints(0,5,2,1,GridBagConstraints.WEST); + gbc.insets = new Insets(0,5,5,5); + includeFollowAccess.setToolTipText("To be able to follow channels."); + add(includeFollowAccess,gbc); + // URL Display and Buttons - gbc = makeGridBagConstraints(0,5,2,1,GridBagConstraints.CENTER); + gbc = makeGridBagConstraints(0,6,2,1,GridBagConstraints.CENTER); gbc.fill = GridBagConstraints.HORIZONTAL; gbc.weightx = 1; urlField.setEditable(false); add(urlField, gbc); - gbc = makeGridBagConstraints(0,6,1,1,GridBagConstraints.EAST); + gbc = makeGridBagConstraints(0,7,1,1,GridBagConstraints.EAST); gbc.insets = new Insets(0,5,10,5); add(copyUrl,gbc); - gbc = makeGridBagConstraints(1,6,1,1,GridBagConstraints.EAST); + gbc = makeGridBagConstraints(1,7,1,1,GridBagConstraints.EAST); gbc.insets = new Insets(0,0,10,5); add(openUrl,gbc); // Status and Close Button - add(status,makeGridBagConstraints(0,7,2,1,GridBagConstraints.CENTER)); - add(close,makeGridBagConstraints(1,8,1,1,GridBagConstraints.EAST)); + add(status,makeGridBagConstraints(0,8,2,1,GridBagConstraints.CENTER)); + add(close,makeGridBagConstraints(1,9,1,1,GridBagConstraints.EAST)); openUrl.addActionListener(this); copyUrl.addActionListener(this); @@ -109,6 +119,7 @@ public TokenGetDialog(MainGui owner) { includeCommercialAccess.addItemListener(this); includeReadUserAccess.addItemListener(this); includeShowSubsAccess.addItemListener(this); + includeFollowAccess.addItemListener(this); reset(); updateUrl(); @@ -172,6 +183,9 @@ private void updateUrl() { if (includeShowSubsAccess.isSelected()) { url += "+channel_subscriptions"; } + if (includeFollowAccess.isSelected()) { + url += "+user_follows_edit"; + } currentUrl = url; urlField.setText(url); urlField.setToolTipText(url); diff --git a/src/chatty/gui/components/menus/ChannelContextMenu.java b/src/chatty/gui/components/menus/ChannelContextMenu.java index e0bc0d623..a0f9523e9 100644 --- a/src/chatty/gui/components/menus/ChannelContextMenu.java +++ b/src/chatty/gui/components/menus/ChannelContextMenu.java @@ -28,6 +28,9 @@ public ChannelContextMenu(ContextMenuListener listener) { addItem("joinHostedChannel", "Join Hosted Channel", MISC_MENU); addItem("copy", "Copy Stream Name", MISC_MENU); addSeparator(MISC_MENU); + addItem("follow", "Follow Channel", MISC_MENU); + addItem("unfollow", "Unfollow Channel", MISC_MENU); + addSeparator(MISC_MENU); addItem("srcOpen", "Open Speedrun.com", MISC_MENU); addSeparator(); addItem("closeChannel", "Close Channel"); diff --git a/src/chatty/gui/components/menus/ContextMenuHelper.java b/src/chatty/gui/components/menus/ContextMenuHelper.java index 555cc7761..ad94b9336 100644 --- a/src/chatty/gui/components/menus/ContextMenuHelper.java +++ b/src/chatty/gui/components/menus/ContextMenuHelper.java @@ -81,7 +81,11 @@ protected static void addStreamsOptions(ContextMenu m, int numStreams, boolean j m.addItem("join", "Join " + count + "channel" + s); m.addSeparator(); m.addItem("hostchannel", "Host Channel", miscSubmenu); + m.addSeparator(miscSubmenu); m.addItem("copy", "Copy Stream Name", miscSubmenu); + m.addSeparator(miscSubmenu); + m.addItem("follow", "Follow Channel", miscSubmenu); + m.addItem("unfollow", "Unfollow Channel", miscSubmenu); } } diff --git a/src/chatty/gui/components/menus/UserContextMenu.java b/src/chatty/gui/components/menus/UserContextMenu.java index fb10ab86e..6583a02ad 100644 --- a/src/chatty/gui/components/menus/UserContextMenu.java +++ b/src/chatty/gui/components/menus/UserContextMenu.java @@ -17,7 +17,7 @@ public class UserContextMenu extends ContextMenu { private final ContextMenuListener listener; private final User user; - private static final String MISC = "Miscellaneous"; + private static final String MISC_MENU = "Miscellaneous"; public UserContextMenu(User user, ContextMenuListener listener) { this.listener = listener; @@ -30,10 +30,14 @@ public UserContextMenu(User user, ContextMenuListener listener) { addItem("join","Join #"+user.getNick()); addSeparator(); - addItem("copy", "Copy Name", MISC); - addSeparator(MISC); - ContextMenuHelper.addIgnore(this, user.nick, MISC, false); - ContextMenuHelper.addIgnore(this, user.nick, MISC, true); + // Misc Submenu + addItem("copy", "Copy Name", MISC_MENU); + addSeparator(MISC_MENU); + ContextMenuHelper.addIgnore(this, user.nick, MISC_MENU, false); + ContextMenuHelper.addIgnore(this, user.nick, MISC_MENU, true); + addSeparator(MISC_MENU); + addItem("follow", "Follow", MISC_MENU); + addItem("unfollow", "Unfollow", MISC_MENU); // Get the preset categories from the addressbook, which may be empty // if not addressbook is set to this user diff --git a/src/chatty/util/api/StreamInfo.java b/src/chatty/util/api/StreamInfo.java index a43275e36..08d1ca47d 100644 --- a/src/chatty/util/api/StreamInfo.java +++ b/src/chatty/util/api/StreamInfo.java @@ -71,7 +71,7 @@ private enum UpdateResult { UPDATED, CHANGED, SET_OFFLINE }; // How long a stats range can be at most private static final int VIEWERSTATS_MAX_LENGTH = 35*60*1000; - private static final int RECHECK_OFFLINE_DELAY = 10*1000; + private static final int RECHECK_OFFLINE_DELAY = 20*1000; /** * Maximum length in seconds of what should count as a PICNIC (short stream diff --git a/src/chatty/util/api/TokenInfo.java b/src/chatty/util/api/TokenInfo.java index 320144e29..e83da5487 100644 --- a/src/chatty/util/api/TokenInfo.java +++ b/src/chatty/util/api/TokenInfo.java @@ -13,6 +13,7 @@ public class TokenInfo { public final boolean chat_access; public final boolean user_read; public final boolean channel_subscriptions; + public final boolean user_follows_edit; public final String name; public final boolean valid; @@ -24,16 +25,19 @@ public TokenInfo() { user_read = false; name = null; channel_subscriptions = false; + user_follows_edit = false; } public TokenInfo(String name, boolean chat_access, boolean channel_editor, - boolean channel_commercials, boolean user_read, boolean channel_subscriptions) { + boolean channel_commercials, boolean user_read, boolean channel_subscriptions, + boolean user_follows_edit) { this.name = name; this.channel_editor = channel_editor; this.channel_commercials = channel_commercials; this.chat_access = chat_access; this.user_read = user_read; this.channel_subscriptions = channel_subscriptions; + this.user_follows_edit = user_follows_edit; valid = true; } } diff --git a/src/chatty/util/api/TwitchApi.java b/src/chatty/util/api/TwitchApi.java index e33302890..e987f04ba 100644 --- a/src/chatty/util/api/TwitchApi.java +++ b/src/chatty/util/api/TwitchApi.java @@ -4,6 +4,7 @@ import chatty.Chatty; import chatty.Helper; import chatty.Usericon; +import chatty.util.DateTime; import chatty.util.JSONUtil; import chatty.util.StringUtil; import chatty.util.api.FollowerManager.Type; @@ -42,7 +43,7 @@ public class TwitchApi { enum RequestType { STREAM, EMOTICONS, VERIFY_TOKEN, CHAT_ICONS, CHANNEL, CHANNEL_PUT, GAME_SEARCH, COMMERCIAL, STREAMS, FOLLOWED_STREAMS, FOLLOWERS, - SUBSCRIBERS, USERINFO, CHAT_SERVER + SUBSCRIBERS, USERINFO, CHAT_SERVER, FOLLOW, UNFOLLOW } public enum RequestResult { @@ -368,6 +369,30 @@ public void runCommercial(String stream, String token, int length) { } } + public void followChannel(String user, String target) { + String url = String.format( + "https://api.twitch.tv/kraken/users/%s/follows/channels/%s", + user, + target.toLowerCase()); + if (attemptRequest(url, target)) { + TwitchApiRequest request = new TwitchApiRequest(this, RequestType.FOLLOW, url, defaultToken); + request.setRequestType("PUT"); + executor.execute(request); + } + } + + public void unfollowChannel(String user, String target) { + String url = String.format( + "https://api.twitch.tv/kraken/users/%s/follows/channels/%s", + user, + target.toLowerCase()); + if (attemptRequest(url, target)) { + TwitchApiRequest request = new TwitchApiRequest(this, RequestType.UNFOLLOW, url, defaultToken); + request.setRequestType("DELETE"); + executor.execute(request); + } + } + /** * Called by the request thread to work on the result of the request. * @@ -505,6 +530,39 @@ else if (type == RequestType.SUBSCRIBERS) { String stream = removeRequest(url); subscriberManager.received(responseCode, stream, result); } + else if (type == RequestType.FOLLOW) { + String target = removeRequest(url); + System.out.println(result); + if (responseCode == 200) { + long followTime = followGetTime(result); + if (followTime != -1 && System.currentTimeMillis() - followTime > 5000) { + resultListener.followResult(String.format("Already following '%s' (since %s)", + target, + DateTime.ago(followTime, 0, 1, 0, DateTime.Formatting.VERBOSE))); + } else { + resultListener.followResult("Now following '"+target+"'"); + } + } else if (responseCode == 404) { + resultListener.followResult("Couldn't follow '"+target+"' (channel not found)"); + } else if (responseCode == 401) { + resultListener.followResult("Couldn't follow '"+target+"' (access denied)"); + } else { + resultListener.followResult("Couldn't follow '"+target+"' (unknown error)"); + } + } + else if (type == RequestType.UNFOLLOW) { + String target = removeRequest(url); + System.out.println(result); + if (responseCode == 204) { + resultListener.followResult("No longer following '"+target+"'"); + } else if (responseCode == 404) { + resultListener.followResult("Couldn't unfollow '"+target+"' (channel not found)"); + } else if (responseCode == 401) { + resultListener.followResult("Couldn't unfollow '"+target+"' (access denied)"); + } else { + resultListener.followResult("Couldn't unfollow '"+target+"' (unknown error)"); + } + } } /** @@ -633,8 +691,9 @@ private TokenInfo parseVerifyToken(String json) { boolean chatAccess = scopes.contains("chat_login"); boolean userAccess = scopes.contains("user_read"); boolean readSubscriptions = scopes.contains("channel_subscriptions"); + boolean userEditFollows = scopes.contains("user_follows_edit"); - return new TokenInfo(username, chatAccess, allowEditor, allowCommercials, userAccess, readSubscriptions); + return new TokenInfo(username, chatAccess, allowEditor, allowCommercials, userAccess, readSubscriptions, userEditFollows); } catch (ParseException e) { return null; @@ -813,4 +872,15 @@ private String parseServer(String json) { } return null; } + + private long followGetTime(String json) { + try { + JSONParser parser = new JSONParser(); + JSONObject root = (JSONObject) parser.parse(json); + long time = Util.parseTime((String)root.get("created_at")); + return time; + } catch (Exception ex) { + return -1; + } + } } \ No newline at end of file diff --git a/src/chatty/util/api/TwitchApiResultListener.java b/src/chatty/util/api/TwitchApiResultListener.java index 325c88e6e..2a7e76801 100644 --- a/src/chatty/util/api/TwitchApiResultListener.java +++ b/src/chatty/util/api/TwitchApiResultListener.java @@ -35,4 +35,11 @@ public interface TwitchApiResultListener { void receivedDisplayName(String name, String displayName); void receivedServer(String channel, String server); + + /** + * Human-readable result message. + * + * @param message + */ + void followResult(String message); }