diff --git a/src/chatty/Chatty.java b/src/chatty/Chatty.java index 8bfb177a8..57da665db 100644 --- a/src/chatty/Chatty.java +++ b/src/chatty/Chatty.java @@ -54,7 +54,7 @@ public class Chatty { * by points. May contain a single "b" for beta versions, anything following * it will be ignored for version checking. */ - public static final String VERSION = "0.8.4b4"; + public static final String VERSION = "0.8.4b5"; /** * Enable Version Checker (if you compile and distribute this yourself, you diff --git a/src/chatty/Logging.java b/src/chatty/Logging.java index 4e373eb0b..aa6a1229d 100644 --- a/src/chatty/Logging.java +++ b/src/chatty/Logging.java @@ -80,6 +80,9 @@ public void publish(LogRecord record) { if (record.getSourceClassName().startsWith("chatty.util.ffz.Websocket")) { client.debugFFZ(record.getMessage()); } + if (record.getSourceClassName().startsWith("chatty.util.api.pubsub.")) { + client.debugPubSub(record.getMessage()); + } } if (record.getLevel() == Level.SEVERE) { if (client.g != null) { diff --git a/src/chatty/SettingsManager.java b/src/chatty/SettingsManager.java index d5d8f74cc..2adb18ab6 100644 --- a/src/chatty/SettingsManager.java +++ b/src/chatty/SettingsManager.java @@ -128,6 +128,7 @@ void defineSettings() { settings.addList("securedPorts", new LinkedHashSet<>(Arrays.asList((long)6697, (long)443)), Setting.LONG); settings.addBoolean("membershipEnabled", true); + settings.addString("pubsub", "wss://pubsub-edge.twitch.tv"); // Auto-join channels settings.addString("channel", ""); @@ -414,6 +415,7 @@ void defineSettings() { settings.addBoolean("showModMessages", false); settings.addBoolean("twitchnotifyAsInfo", true); settings.addBoolean("printStreamStatus", true); + settings.addBoolean("showModActions", false); // Timeouts/Bans settings.addBoolean("showBanMessages", false); @@ -463,6 +465,7 @@ void defineSettings() { settings.addBoolean("logInfo", true); settings.addBoolean("logViewerstats", true); settings.addBoolean("logViewercount", false); + settings.addBoolean("logModAction", true); settings.addList("logWhitelist",new ArrayList(), Setting.STRING); settings.addList("logBlacklist",new ArrayList(), Setting.STRING); settings.addString("logPath", ""); diff --git a/src/chatty/TwitchClient.java b/src/chatty/TwitchClient.java index 38e35ef22..035fc1dcd 100644 --- a/src/chatty/TwitchClient.java +++ b/src/chatty/TwitchClient.java @@ -37,6 +37,10 @@ import chatty.util.api.FollowerInfo; import chatty.util.api.StreamInfo.ViewerStats; import chatty.util.api.TwitchApi.RequestResult; +import chatty.util.api.UserIDs; +import chatty.util.api.pubsub.Message; +import chatty.util.api.pubsub.ModeratorActionData; +import chatty.util.api.pubsub.PubSubListener; import chatty.util.chatlog.ChatLog; import chatty.util.settings.Settings; import chatty.util.settings.SettingsListener; @@ -94,6 +98,8 @@ public class TwitchClient { */ public final TwitchApi api; + public final chatty.util.api.pubsub.Manager pubsub; + public final TwitchEmotes twitchemotes; public final BTTVEmotes bttvEmotes; @@ -187,6 +193,9 @@ public TwitchClient(Map args) { settingsManager.overrideSettings(); settingsManager.debugSettings(); + pubsub = new chatty.util.api.pubsub.Manager( + settings.getString("pubsub"), new PubSubResults(), api); + frankerFaceZ = new FrankerFaceZ(new EmoticonsListener(), settings); frankerFaceZ.autoUpdateFeatureFridayEmotes(); @@ -915,6 +924,9 @@ else if (command.equals("ffzglobal")) { else if (command.equals("ffzws")) { g.printSystem("[FFZ-WS] Status: "+frankerFaceZ.getWsStatus()); } + else if (command.equals("pubsubstatus")) { + g.printSystem("[PubSub] Status: "+pubsub.getStatus()); + } else if (command.equals("refresh")) { commandRefresh(channel, parameter); } @@ -1117,6 +1129,16 @@ else if (command.equals("bantest")) { frankerFaceZ.connectWs(); } else if (command.equals("wsdisconnect")) { frankerFaceZ.disconnectWs(); + } else if (command.equals("psconnect")) { + pubsub.connect(); + } else if (command.equals("psdisconnect")) { + pubsub.disconnect(); + } else if (command.equals("modactiontest")) { + List args = new ArrayList(); + args.add("tirean"); + args.add("300"); + args.add("still not using LiveSplit Autosplitter D:"); + g.printModerationAction(new ModeratorActionData("", "", "tduvatest", "timeout", args, "tduva")); } else if (command.equals("loadsoferrors")) { for (int i=0;i<10000;i++) { SwingUtilities.invokeLater(new Runnable() { @@ -1127,6 +1149,14 @@ public void run() { } }); } + } else if (command.equals("getuserid")) { + g.printSystem(parameter+": "+api.getUserId(parameter, new UserIDs.UserIDListener() { + + @Override + public void setUserId(String username, long userId) { + g.printSystem(username+": "+userId); + } + })); } } @@ -1554,6 +1584,13 @@ public void debugFFZ(String line) { g.printDebugFFZ(line); } + public void debugPubSub(String line) { + if (shuttingDown || g == null) { + return; + } + g.printDebugPubSub(line); + } + /** * Output a warning. * @@ -1580,6 +1617,23 @@ public final void warning(String line) { } } + private class PubSubResults implements PubSubListener { + + @Override + public void messageReceived(Message message) { + if (message.data != null && message.data instanceof ModeratorActionData) { + ModeratorActionData data = (ModeratorActionData)message.data; + g.printModerationAction(data); + chatLog.modAction(data); + } + } + + @Override + public void info(String info) { + g.printDebugPubSub(info); + } + + } /** * Redirects request results from the API. @@ -2038,6 +2092,7 @@ public void exit() { logAllViewerstats(); c.disconnect(); frankerFaceZ.disconnectWs(); + pubsub.disconnect(); g.cleanUp(); chatLog.close(); System.exit(0); @@ -2232,6 +2287,7 @@ public void onBan(User user, long duration, String reason) { @Override public void onRegistered() { g.updateHighlightSetUsername(c.getUsername()); + pubsub.listenModLog(c.getUsername(), settings.getString("token")); } @Override diff --git a/src/chatty/gui/MainGui.java b/src/chatty/gui/MainGui.java index 5b0919df7..7419b1442 100644 --- a/src/chatty/gui/MainGui.java +++ b/src/chatty/gui/MainGui.java @@ -36,6 +36,7 @@ import chatty.gui.components.FollowersDialog; import chatty.gui.components.LiveStreamsDialog; import chatty.gui.components.LivestreamerDialog; +import chatty.gui.components.ModerationLog; import chatty.gui.components.NewsDialog; import chatty.gui.components.srl.SRL; import chatty.gui.components.SearchDialog; @@ -59,6 +60,7 @@ import chatty.util.api.Emoticons.TagEmotes; import chatty.util.api.FollowerInfo; import chatty.util.api.TwitchApi.RequestResult; +import chatty.util.api.pubsub.ModeratorActionData; import chatty.util.hotkeys.HotkeyManager; import chatty.util.settings.Setting; import chatty.util.settings.SettingChangeListener; @@ -125,6 +127,7 @@ public class MainGui extends JFrame implements Runnable { private FollowersDialog followerDialog; private FollowersDialog subscribersDialog; private StreamChat streamChat; + private ModerationLog moderationLog; // Helpers private final Highlighter highlighter = new Highlighter(); @@ -252,6 +255,8 @@ private void createGui() { streamChat = new StreamChat(this, styleManager, contextMenuListener, client.settings.getBoolean("streamChatBottom")); + moderationLog = new ModerationLog(this); + //this.getContentPane().setBackground(new Color(0,0,0,0)); getSettingsDialog(); @@ -286,6 +291,7 @@ private void createGui() { windowStateManager.addWindow(emotesDialog, "emotes", true, true); windowStateManager.addWindow(followerDialog, "followers", true, true); windowStateManager.addWindow(subscribersDialog, "subscribers", true, true); + windowStateManager.addWindow(moderationLog, "moderationLog", true, true); windowStateManager.addWindow(streamChat, "streamChat", true, true); guiCreated = true; @@ -467,6 +473,15 @@ public void actionPerformed(ActionEvent e) { } }); + addMenuAction("dialog.moderationLog", "Dialog: Toggle Moderation Log", + "Moderation Log", KeyEvent.VK_UNDEFINED, new AbstractAction() { + + @Override + public void actionPerformed(ActionEvent e) { + toggleModerationLog(); + } + }); + addMenuAction("dialog.addressbook", "Dialog: Toggle Addressbook", "Addressbook", KeyEvent.VK_UNDEFINED, new AbstractAction() { @@ -905,14 +920,21 @@ public void saveWindowStates() { * Reopen some windows if enabled. */ private void reopenWindows() { - reopenWindow(liveStreamsDialog); - reopenWindow(highlightedMessages); - reopenWindow(ignoredMessages); - reopenWindow(channelInfoDialog); - reopenWindow(addressbookDialog); - reopenWindow(adminDialog); - reopenWindow(emotesDialog); - reopenWindow(streamChat); +// reopenWindow(liveStreamsDialog); +// reopenWindow(highlightedMessages); +// reopenWindow(ignoredMessages); +// reopenWindow(channelInfoDialog); +// reopenWindow(addressbookDialog); +// reopenWindow(adminDialog); +// reopenWindow(emotesDialog); +// reopenWindow(streamChat); +// reopenWindow(moderationLog); +// reopenWindow(followerDialog); +// reopenWindow(subscribersDialog); + + for (Window window : windowStateManager.getWindows()) { + reopenWindow(window); + } } /** @@ -940,6 +962,8 @@ private void reopenWindow(Window window) { openFollowerDialog(); } else if (window == subscribersDialog) { openSubscriberDialog(); + } else if (window == moderationLog) { + openModerationLog(); } else if (window == streamChat) { openStreamChat(); } @@ -2102,6 +2126,17 @@ private void toggleSubscriberDialog() { } } + private void openModerationLog() { + windowStateManager.setWindowPosition(moderationLog); + moderationLog.showDialog(); + } + + private void toggleModerationLog() { + if (!closeDialog(moderationLog)) { + openModerationLog(); + } + } + private void openUpdateDialog() { updateMessage.setLocationRelativeTo(this); updateMessage.showDialog(); @@ -2788,6 +2823,33 @@ public void run() { }); } + public void printDebugPubSub(final String line) { + SwingUtilities.invokeLater(new Runnable() { + + @Override + public void run() { + debugWindow.printLinePubSub(line); + } + }); + } + + public void printModerationAction(final ModeratorActionData data) { + SwingUtilities.invokeLater(new Runnable() { + + @Override + public void run() { + moderationLog.add(data); + String channel = Helper.toValidChannel(data.stream); + if (client.settings.getBoolean("showModActions") && channels.isChannel(channel)) { + channels.getChannel(channel).printLine(String.format("[ModAction] %s: /%s %s", + data.created_by, + data.moderation_action, + StringUtil.join(data.args, " "))); + } + } + }); + } + /** * Outputs a line to the debug window * diff --git a/src/chatty/gui/MainMenu.java b/src/chatty/gui/MainMenu.java index 068b2684e..10b61b7c0 100644 --- a/src/chatty/gui/MainMenu.java +++ b/src/chatty/gui/MainMenu.java @@ -144,6 +144,8 @@ public MainMenu(ActionListener actionListener, ItemListener itemListener, addItem(extra,"dialog.followers","Followers"); addItem(extra,"dialog.subscribers","Subscribers"); extra.addSeparator(); + addItem(extra,"dialog.moderationLog", "Moderation Log"); + extra.addSeparator(); JMenu streamChat = new JMenu("Stream Chat"); addItem(streamChat,"dialog.streamchat", "Open"); addCheckboxItem(streamChat, "streamChatResizable", "Resizable"); diff --git a/src/chatty/gui/WindowStateManager.java b/src/chatty/gui/WindowStateManager.java index 6e82174eb..780e3187e 100644 --- a/src/chatty/gui/WindowStateManager.java +++ b/src/chatty/gui/WindowStateManager.java @@ -9,6 +9,7 @@ import java.awt.Window; import java.util.HashMap; import java.util.HashSet; +import java.util.Map; import java.util.Set; import java.util.logging.Logger; @@ -82,6 +83,10 @@ public void addWindow(Window window, String id, boolean saveSize, boolean reopen attachedWindowManager.attach(window); } + public Set getWindows() { + return new HashSet<>(windows.keySet()); + } + /** * Sets the primary window, which can be restored even if the other windows * are set to not restore. @@ -239,7 +244,9 @@ public boolean wasOpen(Window window) { * @return {@code true} if it should be reopened, {@code false} otherwise */ public boolean shouldReopen(Window window) { - return mode() >= REOPEN_ON_START && wasOpen(window); + StateItem item = windows.get(window); + return mode() >= REOPEN_ON_START + && item != null && item.reopen && item.wasOpen; } /** diff --git a/src/chatty/gui/components/DebugWindow.java b/src/chatty/gui/components/DebugWindow.java index 60f3cc765..e10c153c2 100644 --- a/src/chatty/gui/components/DebugWindow.java +++ b/src/chatty/gui/components/DebugWindow.java @@ -28,6 +28,7 @@ public class DebugWindow extends JFrame { private final JTextArea text; private final JTextArea textIrcLog; private final JTextArea textFFZLog; + private final JTextArea textPubSubLog; public DebugWindow(ItemListener listener) { setTitle("Debug"); @@ -40,12 +41,16 @@ public DebugWindow(ItemListener listener) { // FFZ WS log textFFZLog = createLogArea(); + + // PubSub WS log + textPubSubLog = createLogArea(); // Tabs JTabbedPane tabs = new JTabbedPane(); tabs.addTab("Log", new JScrollPane(text)); tabs.addTab("Irc log", new JScrollPane(textIrcLog)); tabs.addTab("FFZ-WS", new JScrollPane(textFFZLog)); + tabs.addTab("PubSub", new JScrollPane(textPubSubLog)); // Settings (Checkboxes) logIrc.setToolTipText("Logging IRC traffic can reduce performance"); @@ -88,6 +93,10 @@ public void printLineFFZ(String line) { printLine(textFFZLog, line); } + public void printLinePubSub(String line) { + printLine(textPubSubLog, line); + } + private void printLine(JTextArea text, String line) { try { Document doc = text.getDocument(); diff --git a/src/chatty/gui/components/ModerationLog.java b/src/chatty/gui/components/ModerationLog.java new file mode 100644 index 000000000..6afe87be3 --- /dev/null +++ b/src/chatty/gui/components/ModerationLog.java @@ -0,0 +1,117 @@ + +package chatty.gui.components; + +import chatty.gui.MainGui; +import chatty.util.DateTime; +import chatty.util.StringUtil; +import chatty.util.api.pubsub.ModeratorActionData; +import java.awt.BorderLayout; +import javax.swing.JDialog; +import javax.swing.JScrollBar; +import javax.swing.JScrollPane; +import javax.swing.JTextArea; +import javax.swing.text.BadLocationException; +import javax.swing.text.DefaultCaret; +import javax.swing.text.Document; +import javax.swing.text.Element; + +/** + * + * @author tduva + */ +public class ModerationLog extends JDialog { + + private static final int MAX_NUMBER_LINES = 1000; + + private final JTextArea log; + private final JScrollPane scroll; + + public ModerationLog(MainGui owner) { + super(owner); + log = createLogArea(); + log.setSize(300, 200); + setTitle("Moderator Actions"); + + scroll = new JScrollPane(log); + add(scroll, BorderLayout.CENTER); + pack(); + } + + private static JTextArea createLogArea() { + // Caret to prevent scrolling + DefaultCaret caret = new DefaultCaret(); + caret.setUpdatePolicy(DefaultCaret.NEVER_UPDATE); + + JTextArea text = new JTextArea(); + text.setLineWrap(true); + text.setWrapStyleWord(true); + text.setEditable(false); + text.setCaret(caret); + return text; + } + + public void add(ModeratorActionData data) { + if (data.stream != null) { + setTitle("Moderator Actions ("+data.stream+")"); + } + String line = String.format("%s: /%s %s", + data.created_by, + data.moderation_action, + StringUtil.join(data.args," ")); + printLine(log, line); + } + + private void printLine(JTextArea text, String line) { + try { + Document doc = text.getDocument(); + String linebreak = doc.getLength() > 0 ? "\n" : ""; + doc.insertString(doc.getLength(), linebreak+"["+DateTime.currentTime()+"] "+line, null); + JScrollBar bar = scroll.getVerticalScrollBar(); + boolean scrollDown = bar.getValue() == bar.getMaximum() - bar.getVisibleAmount(); + if (scrollDown) { + text.setCaretPosition(doc.getLength()); + } + clearSomeChat(doc); + } catch (BadLocationException e) { + e.printStackTrace(); + } + } + + /** + * Removes some lines from the given Document so it won't exceed the maximum + * number of lines. + * + * @param doc + */ + public void clearSomeChat(Document doc) { + int count = doc.getDefaultRootElement().getElementCount(); + if (count > MAX_NUMBER_LINES) { + removeFirstLines(doc, 10); + } + } + + /** + * Removes the given number of lines from the given Document. + * + * @param doc + * @param amount + */ + public void removeFirstLines(Document doc, int amount) { + if (amount < 1) { + amount = 1; + } + Element firstToRemove = doc.getDefaultRootElement().getElement(0); + Element lastToRemove = doc.getDefaultRootElement().getElement(amount - 1); + int startOffset = firstToRemove.getStartOffset(); + int endOffset = lastToRemove.getEndOffset(); + try { + doc.remove(startOffset,endOffset); + } catch (BadLocationException ex) { + ex.printStackTrace(); + } + } + + public void showDialog() { + setVisible(true); + } +} diff --git a/src/chatty/gui/components/settings/LogSettings.java b/src/chatty/gui/components/settings/LogSettings.java index b682251e0..bf7be710f 100644 --- a/src/chatty/gui/components/settings/LogSettings.java +++ b/src/chatty/gui/components/settings/LogSettings.java @@ -83,20 +83,46 @@ public void itemStateChanged(ItemEvent e) { JPanel types = createTitledPanel("Message Types"); - types.add(d.addSimpleBooleanSetting("logInfo", "Chat Info", - "Log infos like stream title, messages from twitch, connecting, disconnecting."), d.makeGbcCloser(0, 0, 1, 1, GridBagConstraints.NORTHWEST)); - types.add(d.addSimpleBooleanSetting("logBan", "Bans/Timeouts", - "Log Bans/Timeouts as BAN messages."), d.makeGbcCloser(0, 1, 1, 1, GridBagConstraints.WEST)); - types.add(d.addSimpleBooleanSetting("logMod", "Mod/Unmod", - "Log MOD/UNMOD messages."), d.makeGbcCloser(0, 2, 1, 1, GridBagConstraints.WEST)); - types.add(d.addSimpleBooleanSetting("logJoinPart", "Joins/Parts", - "Log JOIN/PART messages."), d.makeGbcCloser(0, 3, 1, 1, GridBagConstraints.WEST)); - types.add(d.addSimpleBooleanSetting("logSystem", "System Info", - "Messages that concern Chatty rather than chat."), d.makeGbcCloser(0, 4, 1, 1, GridBagConstraints.WEST)); - types.add(d.addSimpleBooleanSetting("logViewerstats", "Viewerstats", - "Log viewercount stats in a semi-regular interval."), d.makeGbcCloser(0, 5, 1, 1, GridBagConstraints.WEST)); - types.add(d.addSimpleBooleanSetting("logViewercount", "Viewercount", - "Log the viewercount as it is updated."), d.makeGbcCloser(0, 6, 1, 1, GridBagConstraints.WEST)); + types.add(d.addSimpleBooleanSetting( + "logInfo", + "Chat Info", + "Log infos like stream title, messages from twitch, connecting, disconnecting."), + d.makeGbcCloser(0, 0, 1, 1, GridBagConstraints.NORTHWEST)); + types.add(d.addSimpleBooleanSetting( + "logBan", + "Bans/Timeouts", + "Log Bans/Timeouts as BAN messages."), + d.makeGbcCloser(0, 1, 1, 1, GridBagConstraints.WEST)); + types.add(d.addSimpleBooleanSetting( + "logMod", + "Mod/Unmod", + "Log MOD/UNMOD messages."), + d.makeGbcCloser(0, 2, 1, 1, GridBagConstraints.WEST)); + types.add(d.addSimpleBooleanSetting( + "logJoinPart", + "Joins/Parts", + "Log JOIN/PART messages."), + d.makeGbcCloser(0, 3, 1, 1, GridBagConstraints.WEST)); + types.add(d.addSimpleBooleanSetting( + "logSystem", + "System Info", + "Messages that concern Chatty rather than chat."), + d.makeGbcCloser(0, 4, 1, 1, GridBagConstraints.WEST)); + types.add(d.addSimpleBooleanSetting( + "logViewerstats", + "Viewerstats", + "Log viewercount stats in a semi-regular interval."), + d.makeGbcCloser(0, 5, 1, 1, GridBagConstraints.WEST)); + types.add(d.addSimpleBooleanSetting( + "logViewercount", + "Viewercount", + "Log the viewercount as it is updated."), + d.makeGbcCloser(0, 6, 1, 1, GridBagConstraints.WEST)); + types.add(d.addSimpleBooleanSetting( + "logModAction", + "Mod Actions", + "Log who performed which command (only your own channel)."), + d.makeGbcCloser(0, 7, 1, 1, GridBagConstraints.WEST)); JPanel otherSettings = createTitledPanel("Other Settings"); diff --git a/src/chatty/gui/components/settings/MessageSettings.java b/src/chatty/gui/components/settings/MessageSettings.java index f54506b30..c6e2407a5 100644 --- a/src/chatty/gui/components/settings/MessageSettings.java +++ b/src/chatty/gui/components/settings/MessageSettings.java @@ -99,6 +99,12 @@ public MessageSettings(final SettingsDialog d) { "Correct readability of usercolors", "If enabled, changes some usercolors to make them more readable on the current background"), d.makeGbc(2, 3, 2, 1, GridBagConstraints.WEST)); + + otherSettingsPanel.add(d.addSimpleBooleanSetting( + "showModActions", + "Show moderator actions in chat (Broadcaster only)", + "Show what commands mods perform in your channel (you can also open Extra - Moderation Log)"), + d.makeGbc(0, 4, 3, 1, GridBagConstraints.WEST)); /** diff --git a/src/chatty/util/api/ChannelInfo.java b/src/chatty/util/api/ChannelInfo.java index 124a8037f..7fe85aff6 100644 --- a/src/chatty/util/api/ChannelInfo.java +++ b/src/chatty/util/api/ChannelInfo.java @@ -12,6 +12,7 @@ */ public class ChannelInfo { + public final long id; public final long time; public final String name; public final long createdAt; @@ -21,10 +22,10 @@ public class ChannelInfo { public final String game; public ChannelInfo(String name, String status, String game) { - this(name, status, game, -1, -1, -1); + this(name, -1, status, game, -1, -1, -1); } - public ChannelInfo(String name, String status, String game, long createdAt, + public ChannelInfo(String name, long id, String status, String game, long createdAt, int followers, int views) { this.status = status; this.game = game; @@ -32,6 +33,7 @@ public ChannelInfo(String name, String status, String game, long createdAt, this.views = views; this.followers = followers; this.name = StringUtil.toLowerCase(name); + this.id = id; this.time = System.currentTimeMillis(); } @@ -45,7 +47,7 @@ public String getGame() { @Override public String toString() { - return name+"/"+status+"/"+game+"/"+createdAt+"/"+followers+"/"+views; + return name+"/"+id+"/"+status+"/"+game+"/"+createdAt+"/"+followers+"/"+views; } } diff --git a/src/chatty/util/api/TwitchApi.java b/src/chatty/util/api/TwitchApi.java index 2b8f80195..717a30d44 100644 --- a/src/chatty/util/api/TwitchApi.java +++ b/src/chatty/util/api/TwitchApi.java @@ -56,6 +56,7 @@ public enum RequestResult { private final EmoticonManager emoticonManager; private final FollowerManager followerManager; private final FollowerManager subscriberManager; + private final UserIDs userIDs; private volatile Long tokenLastChecked = Long.valueOf(0); @@ -76,6 +77,7 @@ public TwitchApi(TwitchApiResultListener apiResultListener, emoticonManager = new EmoticonManager(apiResultListener); followerManager = new FollowerManager(Follower.Type.FOLLOWER, this, resultListener); subscriberManager = new FollowerManager(Follower.Type.SUBSCRIBER, this, resultListener); + userIDs = new UserIDs(this); executor = Executors.newCachedThreadPool(); } @@ -245,6 +247,10 @@ public void getChannelInfo(String stream) { } } + public long getUserId(String username, UserIDs.UserIDListener listener) { + return userIDs.getUserId(username, listener); + } + public void getGameSearch(String game) { if (game == null || game.isEmpty()) { return; @@ -592,6 +598,7 @@ private void handleChannelInfoResult(RequestType type, String url, String result } resultListener.receivedChannelInfo(stream, info, RequestResult.SUCCESS); cachedChannelInfo.put(stream, info); + userIDs.channelInfoReceived(info); } /** @@ -769,6 +776,7 @@ private ChannelInfo parseChannelInfo(String json) { JSONObject root = (JSONObject)parser.parse(json); String name = (String)root.get("name"); + long id = ((Number)root.get("_id")).longValue(); String status = (String)root.get("status"); String game = (String)root.get("game"); int views = JSONUtil.getInteger(root, "views", -1); @@ -779,7 +787,7 @@ private ChannelInfo parseChannelInfo(String json) { } catch (java.text.ParseException ex) { LOGGER.warning("Error parsing ChannelInfo: "+ex); } - return new ChannelInfo(name, status,game, createdAt, followers, views); + return new ChannelInfo(name, id, status, game, createdAt, followers, views); } catch (ParseException ex) { LOGGER.warning("Error parsing ChannelInfo."); diff --git a/src/chatty/util/api/UserIDs.java b/src/chatty/util/api/UserIDs.java new file mode 100644 index 000000000..3c8207040 --- /dev/null +++ b/src/chatty/util/api/UserIDs.java @@ -0,0 +1,182 @@ + +package chatty.util.api; + +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.Timer; +import java.util.TimerTask; +import java.util.logging.Logger; + +/** + * + * @author tduva + */ +public class UserIDs { + + private static final Logger LOGGER = Logger.getLogger(UserIDs.class.getName()); + + private static final int CHECK_PENDING_DELAY = 60*1000; + private static final int MAX_REQUEST_ATTEMPTS = 4; + + private final Object LOCK = new Object(); + + /** + * Stores already known userIDs. + */ + private final Map userIDs = Collections.synchronizedMap(new HashMap()); + + private final Map> listeners = new HashMap<>(); + + /** + * Pending usernames that still require a userId. These are ones that a user + * registered a listener for. Once the listener has been informed the + * username is removed from this. + * + * This counts up how often the userId for this username has been requested, + * so it doesn't just keep requesting it for ever in case of errors. + */ + private final Map pending = new HashMap<>(); + + private final TwitchApi api; + + public UserIDs(TwitchApi api) { + this.api = api; + + Timer checkPending = new Timer("UserIDPending", true); + checkPending.schedule(new TimerTask() { + + @Override + public void run() { + checkPending(); + } + }, CHECK_PENDING_DELAY, CHECK_PENDING_DELAY); + } + + /** + * Get the Twitch User ID for the given username. + * + *

+ * If the userID is not known yet it has to first be requested from the + * Twitch API. For this case you can optionally register a listener in order + * to be informed about the userID being returned. If requesting the userID + * fails too often, the request may be cancelled and the listener discarded. + *

+ * + * @param username A valid Twitch username + * @param listener Optional listener, can be null + * @return The userID, or -1 if no userID is known yet + */ + public long getUserId(String username, UserIDListener listener) { + username = username.toLowerCase(Locale.ENGLISH); + + // Check if userId is already cached + if (userIDs.containsKey(username)) { + return userIDs.get(username); + } + + // Request ChannelInfo which contains userId + ChannelInfo info = api.getCachedChannelInfo(username); + if (info != null && info.id != -1) { + // ChannelInfo was already cached, so got userId immediately + setUserId(info.name, info.id); + return info.id; + } + + // User wants to be informed when the userId is available + if (listener != null) { + synchronized(LOCK) { + if (!listeners.containsKey(username)) { + listeners.put(username, new HashSet()); + } + listeners.get(username).add(listener); + if (!pending.containsKey(username)) { + pending.put(username, 0); + } + } + } + return -1; + } + + public void channelInfoReceived(ChannelInfo info) { + if (info != null) { + setUserId(info.name, info.id); + } + } + + private void setUserId(String username, long userId) { + username = username.toLowerCase(Locale.ENGLISH); + if (userId != -1) { + userIDs.put(username, userId); + Set toInform = getListenersAndRemovePending(username); + if (toInform != null) { + for (UserIDListener listener : toInform) { + listener.setUserId(username, userId); + } + } + } + } + + private Set getListenersAndRemovePending(String username) { + synchronized(LOCK) { + if (listeners.containsKey(username)) { + Set listenersForUsername = listeners.get(username); + if (listenersForUsername.isEmpty()) { + listeners.remove(username); + return null; + } + Set result = new HashSet<>(listenersForUsername); + listeners.remove(username); + pending.remove(username); + return result; + } + return null; + } + } + + private void checkPending() { + String pendingUsername = getOnePendingUsername(); + if (pendingUsername != null) { + getUserId(pendingUsername, null); + } + } + + /** + * Returns one username that still needs the userId. There may be more than + * this one left. Usernames that got returned too often (meaning they've + * been requested several times already without success) are thrown away and + * their associated listeners removed. + * + * @return + */ + private String getOnePendingUsername() { + synchronized(LOCK) { + if (pending.isEmpty()) { + return null; + } + Iterator> it = pending.entrySet().iterator(); + while (it.hasNext()) { + Map.Entry entry = it.next(); + if (entry.getValue() > MAX_REQUEST_ATTEMPTS) { + it.remove(); + listeners.remove(entry.getKey()); + LOGGER.warning("Gave up getting userId for "+entry.getKey()); + } + else { + entry.setValue(entry.getValue() + 1); + return entry.getKey(); + } + } + } + return null; + } + + public interface UserIDListener { + public void setUserId(String username, long userId); + } + +} diff --git a/src/chatty/util/api/pubsub/Client.java b/src/chatty/util/api/pubsub/Client.java new file mode 100644 index 000000000..4c82c54d6 --- /dev/null +++ b/src/chatty/util/api/pubsub/Client.java @@ -0,0 +1,205 @@ + +package chatty.util.api.pubsub; + +import chatty.util.DateTime; +import static chatty.util.MiscUtil.getStackTrace; +import chatty.util.TimedCounter; +import chatty.util.api.pubsub.Client.MyConfigurator; +import java.io.IOException; +import java.net.URI; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.logging.Logger; +import javax.websocket.ClientEndpoint; +import javax.websocket.ClientEndpointConfig; +import javax.websocket.CloseReason; +import javax.websocket.HandshakeResponse; +import javax.websocket.OnClose; +import javax.websocket.OnError; +import javax.websocket.OnMessage; +import javax.websocket.OnOpen; +import javax.websocket.Session; +import org.glassfish.tyrus.client.ClientManager; +import org.glassfish.tyrus.client.ClientProperties; + +/** + * + * @author tduva + */ +@ClientEndpoint(configurator = MyConfigurator.class) +public class Client { + + private static final Logger LOGGER = Logger.getLogger(Client.class.getName()); + + private final MessageHandler handler; + + private final TimedCounter disconnectsPerHour = new TimedCounter(60*60*1000, 0); + private int connectionAttempts; + + private boolean connecting; + private boolean requestedDisconnect; + + private Session s; + + private long connectedSince; + private long lastMessageReceived; + + public Client(MessageHandler handler) { + this.handler = handler; + } + + public synchronized long getLastMessageReceived() { + return lastMessageReceived; + } + + public synchronized String getStatus() { + if (!connecting) { + return "Not connected"; + } + if (s != null && s.isOpen()) { + return String.format("Connected to %s for %s (last message received %s ago)", + s.getRequestURI(), + DateTime.ago(connectedSince), + DateTime.ago(lastMessageReceived)); + } + return "Connecting.."; + } + + public static class MyConfigurator extends ClientEndpointConfig.Configurator { + + @Override + public void beforeRequest(Map> headers) { + // Empty Origin, otherwise would default to server name + headers.put("Origin", Arrays.asList("")); + } + + @Override + public void afterResponse(HandshakeResponse hr) { + } + } + + private class Reconnect extends ClientManager.ReconnectHandler { + + @Override + public boolean onDisconnect(CloseReason closeReason) { + if (requestedDisconnect) { + return false; + } + disconnectsPerHour.increase(); + connectionAttempts++; + LOGGER.info("[PubSub] Reconnecting in "+getDelay()+"s"); + return true; + } + + @Override + public boolean onConnectFailure(Exception exception) { + if (requestedDisconnect) { + LOGGER.info("[PubSub] Cancelled reconnecting.."); + return false; + } + connectionAttempts++; + LOGGER.info(String.format("[PubSub] Another connection attempt (%d) in %ds [%s/%s]", + connectionAttempts, + getDelay(), + exception, + exception.getCause().toString())); + + return true; + } + + @Override + public long getDelay() { + /** + * Wait longer if connection doesn't succeed, however too many + * disconnects after a successful connections in a short period of + * time should slow down connecting as well, just in case. + */ + int disconnects = disconnectsPerHour.getCount(); + return connectionAttempts*connectionAttempts+disconnects*disconnects; + } + + } + + public synchronized void connect(String server) { + if (connecting) { + return; + } + connecting = true; + try { + LOGGER.info("[PubSub] Connecting to "+server); + ClientManager clientManager = ClientManager.createClient(); + clientManager.getProperties().put(ClientProperties.RECONNECT_HANDLER, new Reconnect()); + clientManager.asyncConnectToServer(this, new URI(server)); + } catch (Exception ex) { + LOGGER.warning("[PubSub] Error connecting "+ex); + } + } + + public synchronized void disconnect() { + requestedDisconnect = true; + close(); + } + + public synchronized void reconnect() { + close(); + } + + /** + * Close connection. If requestedDisconnect is false, it will automatically + * reconnect. + */ + private synchronized void close() { + if (s != null) { + try { + s.close(); + } catch (IOException ex) { + LOGGER.warning("[PubSub] Error disconnecting "+ex); + } + } + } + + public synchronized void send(String mesage) { + if (s != null && s.isOpen()) { + s.getAsyncRemote().sendText(mesage); + handler.handleSent(mesage); + System.out.println("SENT:"+mesage); + } + } + + @OnOpen + public synchronized void onOpen(Session session) { + LOGGER.info("[PubSub] Connected to "+session.getRequestURI()); + connectedSince = System.currentTimeMillis(); + this.s = session; + handler.handleConnect(); + } + + @OnMessage + public synchronized void onMessage(String message, Session session) { + lastMessageReceived = System.currentTimeMillis(); + handler.handleReceived(message); + } + + @OnClose + public synchronized void onClose(Session session, CloseReason closeReason) { + LOGGER.info(String.format("[PubSub] Connection closed after %s [%s]", + DateTime.ago(connectedSince), + closeReason)); + s = null; + handler.handleDisconnect(); + } + + @OnError + public void onError(Session session, Throwable t) { + LOGGER.warning("[PubSub] ERROR: "+getStackTrace(t)); + } + + public interface MessageHandler { + public void handleReceived(String text); + public void handleSent(String text); + public void handleConnect(); + public void handleDisconnect(); + } + +} diff --git a/src/chatty/util/api/pubsub/Helper.java b/src/chatty/util/api/pubsub/Helper.java new file mode 100644 index 000000000..0ba1640f6 --- /dev/null +++ b/src/chatty/util/api/pubsub/Helper.java @@ -0,0 +1,43 @@ + +package chatty.util.api.pubsub; + +import java.util.Map; +import org.json.simple.JSONObject; + +/** + * + * @author tduva + */ +public class Helper { + + public static String createOutgoingMessage(String type, String nonce, Object data) { + JSONObject object = new JSONObject(); + object.put("type", type); + if (nonce != null) { + object.put("nonce", nonce); + } + if (data != null) { + object.put("data", data); + } + return object.toJSONString(); + } + + public static String getStreamFromTopic(String topic, Map userIds) { + try { + long userId = Long.valueOf(topic.substring(topic.indexOf(".")+1)); + return userIds.get(userId); + } catch (NumberFormatException ex) { + return null; + } + } + + public static String removeToken(String token, String message) { + return message.replace(token, ""); + } + + public static void main(String[] args) { + String topic = "abc.123"; + System.out.println(Long.valueOf(topic.substring(topic.indexOf(".")+1))); + } + +} diff --git a/src/chatty/util/api/pubsub/Manager.java b/src/chatty/util/api/pubsub/Manager.java new file mode 100644 index 000000000..4eeea08d7 --- /dev/null +++ b/src/chatty/util/api/pubsub/Manager.java @@ -0,0 +1,181 @@ + +package chatty.util.api.pubsub; + +import chatty.util.StringUtil; +import chatty.util.api.TwitchApi; +import chatty.util.api.UserIDs; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Random; +import java.util.Timer; +import java.util.TimerTask; +import java.util.logging.Logger; +import org.json.simple.JSONArray; +import org.json.simple.JSONObject; + +/** + * + * @author tduva + */ +public class Manager { + + private final static Logger LOGGER = Logger.getLogger(Manager.class.getName()); + + private final Client c; + + private final String server; + + private final Map userIds = Collections.synchronizedMap(new HashMap()); + private final Map modLogListen = Collections.synchronizedMap(new HashMap()); + private Timer pingTimer; + private String token; + private final TwitchApi api; + + public Manager(String server, final PubSubListener listener, TwitchApi api) { + this.api = api; + this.server = server; + c = new Client(new Client.MessageHandler() { + + @Override + public void handleReceived(String received) { + listener.info("<< "+StringUtil.trim(received)); + Message message = Message.fromJson(received, userIds); + if (message.type.equals("MESSAGE")) { + listener.messageReceived(message); + } + } + + @Override + public void handleSent(String sent) { + listener.info(">> "+Helper.removeToken(token, sent)); + } + + @Override + public void handleConnect() { + startPinging(); + + for (Long userId : modLogListen.values()) { + if (userId != -1) { + sendListenModLog(userId); + } + } + } + + @Override + public void handleDisconnect() { + + } + }); + } + + /** + * Get a textual representation of the connection status for output to the + * user. + * + * @return + */ + public String getStatus() { + return c.getStatus(); + } + + public void listenModLog(String username, String token) { + if (!hasServer()) { + return; + } + // Already listening, so don't do anything + if (modLogListen.containsKey(username)) { + return; + } + this.token = token; + long userId = getUserId(username); + modLogListen.put(username, userId); + if (userId != -1) { + sendListenModLog(userId); + } + } + + private long getUserId(String username) { + long userId = api.getUserId(username, new UserIDs.UserIDListener() { + + @Override + public void setUserId(String username, long userId) { + Manager.this.setUserId(username, userId); + } + }); + if (userId != -1) { + userIds.put(userId, username); + return userId; + } + LOGGER.info("[PubSub] Pending userId request for "+username); + return -1; + } + + + private void setUserId(String username, long userId) { + userIds.put(userId, username); + + // Topics to still request + if (modLogListen.get(username) == -1) { + modLogListen.put(username, userId); + sendListenModLog(userId); + } + } + + private void sendListenModLog(Long userId) { + JSONArray topics = new JSONArray(); + topics.add("chat_moderator_actions."+userId); + + JSONObject data = new JSONObject(); + data.put("topics", topics); + data.put("auth_token", token); + connect(); + c.send(Helper.createOutgoingMessage("LISTEN", "", data)); + } + + private void startPinging() { + if (pingTimer == null) { + pingTimer = new Timer("PubSubPing", true); + schedulePing(); + } + } + + private void schedulePing() { + pingTimer.schedule(new TimerTask() { + + @Override + public void run() { + c.send(Helper.createOutgoingMessage("PING", null, null)); + schedulePing(); + pingTimer.schedule(new TimerTask() { + + @Override + public void run() { + if (System.currentTimeMillis() - c.getLastMessageReceived() > 15*1000) { + /** + * Checking 10s after PING was send if there was a + * message received in the last 15s. + */ + c.reconnect(); + } + } + }, 10*1000); + } + }, 280*1000+(new Random()).nextInt(5000)); // Random Jitter + } + + private boolean hasServer() { + return server != null && !server.isEmpty(); + } + + public void connect() { + if (hasServer()) { + c.connect(server); + } + } + + public void disconnect() { + c.disconnect(); + } + +} diff --git a/src/chatty/util/api/pubsub/Message.java b/src/chatty/util/api/pubsub/Message.java new file mode 100644 index 000000000..a0e9aadff --- /dev/null +++ b/src/chatty/util/api/pubsub/Message.java @@ -0,0 +1,67 @@ + +package chatty.util.api.pubsub; + +import java.util.Map; +import java.util.logging.Logger; +import org.json.simple.JSONObject; +import org.json.simple.parser.JSONParser; + +/** + * Received message. + * + * @author tduva + */ +public class Message { + + private static final Logger LOGGER = Logger.getLogger(Message.class.getName()); + + /** + * Basic type of the message. Should never be null. + */ + public final String type; + + /** + * Message identifier. Can be null. + */ + public final String nonce; + + /** + * Data of the message. Can be null. + */ + public final MessageData data; + + /** + * Attached error message. Can be null. + */ + public final String error; + + public Message(String type, String nonce, MessageData data, String error) { + this.type = type; + this.nonce = nonce; + this.data = data; + this.error = error; + } + + public static Message fromJson(String json, Map userIds) { + try { + JSONParser parser = new JSONParser(); + JSONObject root = (JSONObject)parser.parse(json); + + String type = (String)root.get("type"); + if (type == null) { + LOGGER.warning("PubSub message type null"); + return null; + } + + String nonce = (String)root.get("nonce"); + String error = (String)root.get("error"); + + MessageData data = MessageData.decode((JSONObject)root.get("data"), userIds); + return new Message(type, nonce, data, error); + } catch (Exception ex) { + LOGGER.warning("Error parsing PubSub message: "+ex); + return null; + } + } + +} diff --git a/src/chatty/util/api/pubsub/MessageData.java b/src/chatty/util/api/pubsub/MessageData.java new file mode 100644 index 000000000..cf1776ec8 --- /dev/null +++ b/src/chatty/util/api/pubsub/MessageData.java @@ -0,0 +1,35 @@ + +package chatty.util.api.pubsub; + +import java.util.Map; +import org.json.simple.JSONObject; +import org.json.simple.parser.ParseException; + +/** + * Data of a message. Different subclasses contain topic specific data. + * + * @author tduva + */ +public class MessageData { + + public final String topic; + public final String message; + + public MessageData(String topic, String message) { + this.topic = topic; + this.message = message; + } + + public static MessageData decode(JSONObject data, Map userIds) throws ParseException { + if (data == null) { + return null; + } + String topic = (String)data.get("topic"); + String message = (String)data.get("message"); + if (topic.startsWith("chat_moderator_actions")) { + return ModeratorActionData.decode(topic, message, userIds); + } + return new MessageData(topic, message); + } + +} diff --git a/src/chatty/util/api/pubsub/ModeratorActionData.java b/src/chatty/util/api/pubsub/ModeratorActionData.java new file mode 100644 index 000000000..29f0bff9c --- /dev/null +++ b/src/chatty/util/api/pubsub/ModeratorActionData.java @@ -0,0 +1,62 @@ + +package chatty.util.api.pubsub; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import org.json.simple.JSONArray; +import org.json.simple.JSONObject; +import org.json.simple.parser.JSONParser; +import org.json.simple.parser.ParseException; + +/** + * Data of the moderator action message. + * + * @author tduva + */ +public class ModeratorActionData extends MessageData { + + public final String moderation_action; + public final List args; + public final String created_by; + public final String stream; + + public ModeratorActionData(String topic, String message, String stream, + String moderation_action, List args, String created_by) { + super(topic, message); + + this.moderation_action = moderation_action; + this.args = args; + this.created_by = created_by; + this.stream = stream; + } + + public static ModeratorActionData decode(String topic, String message, Map userIds) throws ParseException { + JSONParser parser = new JSONParser(); + JSONObject root = (JSONObject)parser.parse(message); + JSONObject data = (JSONObject)root.get("data"); + + String moderation_action = (String)data.get("moderation_action"); + if (moderation_action == null) { + moderation_action = ""; + } + + List args = new ArrayList<>(); + JSONArray argsData = (JSONArray)data.get("args"); + if (argsData != null) { + for (Object argsItem : argsData) { + args.add(String.valueOf(argsItem)); + } + } + + String created_by = (String)data.get("created_by"); + if (created_by == null) { + created_by = ""; + } + + String stream = Helper.getStreamFromTopic(topic, userIds); + + return new ModeratorActionData(topic, message, stream, moderation_action, args, created_by); + } + +} diff --git a/src/chatty/util/api/pubsub/PubSubListener.java b/src/chatty/util/api/pubsub/PubSubListener.java new file mode 100644 index 000000000..7bbdb3102 --- /dev/null +++ b/src/chatty/util/api/pubsub/PubSubListener.java @@ -0,0 +1,11 @@ + +package chatty.util.api.pubsub; + +/** + * + * @author tduva + */ +public interface PubSubListener { + public void messageReceived(Message message); + public void info(String info); +} diff --git a/src/chatty/util/chatlog/ChatLog.java b/src/chatty/util/chatlog/ChatLog.java index 56d8d0adf..d687ad5c1 100644 --- a/src/chatty/util/chatlog/ChatLog.java +++ b/src/chatty/util/chatlog/ChatLog.java @@ -5,7 +5,9 @@ import chatty.Helper; import chatty.User; import chatty.util.DateTime; +import chatty.util.StringUtil; import chatty.util.api.StreamInfo.ViewerStats; +import chatty.util.api.pubsub.ModeratorActionData; import chatty.util.settings.Settings; import java.nio.file.InvalidPathException; import java.nio.file.Path; @@ -106,6 +108,19 @@ public void info(String channel, String message) { } } + public void modAction(ModeratorActionData data) { + if (!Helper.validateStream(data.stream)) { + return; + } + String channel = Helper.toChannel(data.stream); + if (isTypeEnabled("ModAction") && isEnabled(channel)) { + writeLine(channel, timestamp()+String.format("MOD_ACTION: %s (%s%s)", + data.created_by, + data.moderation_action, + data.args.isEmpty() ? "" : " "+StringUtil.join(data.args, " "))); + } + } + public void viewerstats(String channel, ViewerStats stats) { if (isTypeEnabled("Viewerstats") && isEnabled(channel)) { if (stats != null && stats.isValid()) {