diff --git a/src/chatty/TwitchClient.java b/src/chatty/TwitchClient.java index a1d8c4932..c2b18aa5c 100644 --- a/src/chatty/TwitchClient.java +++ b/src/chatty/TwitchClient.java @@ -1628,6 +1628,11 @@ private void addCommands() { commands.add("marker", p -> { commandAddStreamMarker(p.getRoom(), p.getArgs()); }); + commands.add("createClip", p -> { + api.createClip(p.getRoom().getStream(), result -> { + g.printLine(p.getRoom(), result); + }); + }); c.addNewCommands(commands, this); commands.add("addStreamHighlight", p -> { commandAddStreamHighlight(p.getRoom(), p.getArgs()); diff --git a/src/chatty/User.java b/src/chatty/User.java index a132b5d46..65fcd44f3 100644 --- a/src/chatty/User.java +++ b/src/chatty/User.java @@ -1,6 +1,7 @@ package chatty; +import chatty.gui.Highlighter; import chatty.gui.colors.UsercolorManager; import chatty.util.api.usericons.Usericon; import chatty.util.api.usericons.UsericonManager; @@ -588,6 +589,38 @@ public synchronized int getNumberOfSimilarChatMessages(String compareMsg, int me return result; } + public synchronized int getMatchingMessages(Highlighter.HighlightItem item, int num, long time, boolean beforeTime) { + if (lines == null) { + return 0; + } + int result = 0; + int numMsgs = 0; + for (int i=lines.size() - 1; i>=0; i--) { + Message m = lines.get(i); + if (beforeTime) { + if (m.time > time) { + continue; + } + } + else { + if (m.time < time) { + return result; + } + } + if (m instanceof TextMessage) { + TextMessage tm = (TextMessage) m; + if (item.matchesTextOnly(tm.text, null)) { + result++; + } + numMsgs++; + if (numMsgs == num) { + return result; + } + } + } + return result; + } + public synchronized int getNumberOfMessagesAfterBan() { if (lines == null) { return -1; diff --git a/src/chatty/gui/Channels.java b/src/chatty/gui/Channels.java index c15e193b0..cfaeafb44 100644 --- a/src/chatty/gui/Channels.java +++ b/src/chatty/gui/Channels.java @@ -1601,6 +1601,12 @@ private static boolean isChanOffline(DockContent content) { } return false; } + + public void closeModPanels() { + for (Channel chan : getChannels()) { + chan.closeModPanel(); + } + } /** * Sets the focus to the input bar when clicked anywhere on the channel. diff --git a/src/chatty/gui/Highlighter.java b/src/chatty/gui/Highlighter.java index 9e7824f60..e34a8d5c4 100644 --- a/src/chatty/gui/Highlighter.java +++ b/src/chatty/gui/Highlighter.java @@ -6,6 +6,7 @@ import chatty.Helper; import chatty.Logging; import chatty.User; +import chatty.util.DateTime; import chatty.util.Debugging; import chatty.util.MiscUtil; import chatty.util.Pair; @@ -441,12 +442,12 @@ private void clearRecentMatches() { * retrieve all match indices and some meta information. */ public static class HighlightItem { - + public enum Type { REGULAR("Regular chat messages"), INFO("Info messages"), ANY("Any type of message"), - TEXT_MATCH_TEST("Only match text, any message type"); + TEXT_MATCHING_ONLY("Only match text, any message type"); public final String description; @@ -618,6 +619,12 @@ public boolean matches(Type type, String text, int msgStart, int msgEnd, Blackli private List routingTargets; + private int msgsReq = 1; + private int msgsLimit = 0; + private long msgsDuration = 0; + private boolean msgsBeforeDuration; + private boolean msgsMatchOuter; + //-------------------------- // Debugging //-------------------------- @@ -812,6 +819,103 @@ else if (item.startsWith("!status:")) { return checkStatus(user, s, false); }); } + else if (item.startsWith("msgs:") || item.startsWith("!msgs:")) { + boolean inverted = item.startsWith("!msgs:"); + if (inverted) { + item = item.substring(1); + } + List listEntries = parseStringListPrefix(item, "msgs:", s -> s); + List hlItems = new ArrayList() { + + @Override + public String toString() { + StringBuilder b = new StringBuilder("\n"); + for (HighlightItem hlItem : this) { + if (b.length() > 1) { + b.append("OR\n"); + } + b.append(" "); + if (hlItem.msgsMatchOuter) { + b.append("Text match from outer item\n"); + } + else { + b.append(hlItem.getMatchInfo().replaceAll("\\n(?!$)", "\n ")); + } + } + return b.toString(); + } + + }; + for (String entry : listEntries) { + hlItems.add(new HighlightItem(entry)); + } + addUserItem(inverted ? "Don't " : "" + "Match user messages", hlItems, user -> { + boolean matched = false; + for (HighlightItem hlItem : hlItems) { + long time = -1; + if (hlItem.msgsDuration > 0) { + time = System.currentTimeMillis() - hlItem.msgsDuration; + } + int num = user.getMatchingMessages( + hlItem.msgsMatchOuter ? HighlightItem.this : hlItem, + hlItem.msgsLimit, + time, + hlItem.msgsBeforeDuration); + if (num >= hlItem.msgsReq) { + matched = true; + break; + } + } + if (inverted) { + return !matched; + } + return matched; + }); + } + else if (item.startsWith("mreq:")) { + try { + msgsReq = Integer.parseInt(parsePrefix(item, "mreq:")); + } + catch (NumberFormatException ex) { + // Change nothing + } + } + else if (item.startsWith("mlimit:")) { + try { + msgsLimit = Integer.parseInt(parsePrefix(item, "mlimit:")); + } + catch (NumberFormatException ex) { + // Change nothing + } + } + else if (item.startsWith("mtime:")) { + String value = parsePrefix(item, "mtime:"); + msgsBeforeDuration = false; + if (value.startsWith(">")) { + value = value.substring(1); + msgsBeforeDuration = true; + } + if (value.startsWith("<")) { + value = value.substring(1); + } + try { + msgsDuration = DateTime.parseDuration(value); + } + catch (NumberFormatException ex) { + // Change nothing + } + } + else if (item.startsWith("mtype:")) { + try { + String value = parsePrefix(item, "mtype:"); + if (value.equals("outer")) { + msgsMatchOuter = true; + } + } + catch (NumberFormatException ex) { + // Change nothing + } + } else if (item.startsWith("mystatus:")) { Set s = parseStatus(parsePrefix(item, "mystatus:")); addLocalUserItem("My User Status", s, user -> { @@ -1888,8 +1992,8 @@ public boolean matchesAny(String text, Blacklist blacklist) { return matches(Type.ANY, text, blacklist, null, null); } - public boolean matchesTest(String text, Blacklist blacklist) { - return matches(Type.TEXT_MATCH_TEST, text, blacklist, null, null); + public boolean matchesTextOnly(String text, Blacklist blacklist) { + return matches(Type.TEXT_MATCHING_ONLY, text, blacklist, null, null); } public boolean matches(Type type, String text, User user, User localUser, MsgTags tags) { @@ -1964,7 +2068,7 @@ public boolean matches(Type type, String text, int msgStart, int msgEnd, // Type //------ if (type != appliesToType && appliesToType != Type.ANY - && type != Type.ANY && type != Type.TEXT_MATCH_TEST) { + && type != Type.ANY && type != Type.TEXT_MATCHING_ONLY) { return false; } @@ -2000,7 +2104,7 @@ public boolean matches(Type type, String text, int msgStart, int msgEnd, // System.out.println(raw); for (Item item : matchItems) { - if (type == Type.TEXT_MATCH_TEST && !item.matchesOnText) { + if (type == Type.TEXT_MATCHING_ONLY && !item.matchesOnText) { continue; } boolean match = item.matches(type, text, msgStart, msgEnd, blacklist, channel, ab, user, localUser, tags); diff --git a/src/chatty/gui/MainGui.java b/src/chatty/gui/MainGui.java index a2f7b43f9..6a3617765 100644 --- a/src/chatty/gui/MainGui.java +++ b/src/chatty/gui/MainGui.java @@ -2637,6 +2637,7 @@ public void stateChanged(ChangeEvent e) { openFollowerDialog(); } } + channels.closeModPanels(); dockedDialogs.activeContentChanged(); routingManager.setChannel(channels.getLastActiveChannel()); state.update(true); diff --git a/src/chatty/gui/components/Channel.java b/src/chatty/gui/components/Channel.java index 47492b522..d13709b67 100644 --- a/src/chatty/gui/components/Channel.java +++ b/src/chatty/gui/components/Channel.java @@ -54,6 +54,7 @@ public enum Type { private static final int DIVIDER_SIZE = 5; + private final JPanel inputPanel; private final ChannelEditBox input; private final ChannelTextPane text; private final UserList users; @@ -118,7 +119,7 @@ public Channel(final Room room, Type type, MainGui main, StyleManager styleManag installLimits(input); TextSelectionMenu.install(input); - JPanel inputPanel = new JPanel(new BorderLayout()); + inputPanel = new JPanel(new BorderLayout()); inputPanel.add(input, BorderLayout.CENTER); modPanelButton = new JButton("M"); @@ -157,7 +158,7 @@ public void updateModPanel() { } } - private void closeModPanel() { + public void closeModPanel() { if (modPanelPopup != null) { modPanelPopup.hide(); modPanelPopup = null; @@ -500,20 +501,20 @@ public Dimension getMinimumSize() { * Toggle visibility for the text input box. */ public final void toggleInput() { - input.setVisible(!input.isVisible()); + inputPanel.setVisible(!inputPanel.isVisible()); revalidate(); } private boolean inputPreviouslyShown = true; public final void hideInput() { - inputPreviouslyShown = input.isVisible(); - input.setVisible(false); + inputPreviouslyShown = inputPanel.isVisible(); + inputPanel.setVisible(false); revalidate(); } public final void restoreInput() { - input.setVisible(inputPreviouslyShown); + inputPanel.setVisible(inputPreviouslyShown); revalidate(); } diff --git a/src/chatty/gui/components/help/help-builtin_commands.html b/src/chatty/gui/components/help/help-builtin_commands.html index b5a9c4315..7f545f2bc 100644 --- a/src/chatty/gui/components/help/help-builtin_commands.html +++ b/src/chatty/gui/components/help/help-builtin_commands.html @@ -74,6 +74,8 @@

Moderation

Other Twitch Commands

Settings / Customization Commands

diff --git a/src/chatty/gui/components/help/help-settings.html b/src/chatty/gui/components/help/help-settings.html index 7d40cf3c3..ac4dc7b6a 100644 --- a/src/chatty/gui/components/help/help-settings.html +++ b/src/chatty/gui/components/help/help-settings.html @@ -1026,6 +1026,39 @@

Meta Prefixes (Matching)

updated on channel join and when sending a message, so e.g. if you get modded while already in the channel it will not be recognized until after you have sent a message. +
  • msgs: / !msgs: matches on the + past messages of the user as they are shown in the User Dialog, + not including the message that triggered this Highlight checking.
    + The value is a list of one or several Highlight items, which will be + used to match against the text of the user's past messages (only one + item has to match, to require several items to match use separate + msgs: prefixes).
    + For example msgs:"mlimit:1 start:!v","!vote" checks if + the latest message begins with "!v" or any message contains "!vote". + The usual text matching related prefixes can be used, as well as + these additional prefixes:
    +
      +
    • mlimit: limits how many messages are checked + (starting from the latest), for example mlimit:2 + only checks the latest two messages (by default it checks + all).
    • +
    • mtime: limits how long ago messages are + checked, for example mtime:1h only checks + messages from the last hour, whereas mtime:>1h + only checks messages from before the last hour (by + default it checks all).
    • +
    • mreq: defines how many messages must match, for + example mreq:3 requires at least three past + messages to match (by default at least one has to match).
    • +
    • mtype:outer ignores text matching in the msgs: + prefix itself (so for msgs:"mtype:outer abc" + the "abc" would have no effect) and instead uses the outer + item's text matching. For example msgs:"mtype:outer" start:!vote + would match if both the message and also a past message + begin with "!vote" (msgs:"start:!vote" start:!vote + would have the same effect).
    • +
    +
  • config: to specify on or more options (separated by comma, no spaces):
    • config:firstmsg - Restrict matching to the diff --git a/src/chatty/gui/components/help/help-troubleshooting.html b/src/chatty/gui/components/help/help-troubleshooting.html index 5736d5cbc..6a763c739 100644 --- a/src/chatty/gui/components/help/help-troubleshooting.html +++ b/src/chatty/gui/components/help/help-troubleshooting.html @@ -279,8 +279,9 @@

      Error: Java is not recognized as an internal or external command (Windows)

      Find errors in debug.log

      -

      See next section. Of course you'll have to navigate to the folder - manually.

      +

      Check if Chatty writes to any files in the debuglogs folder + (by default <homedir>/.chatty/debuglogs, + for example C:\Users\yourname\.chatty\debuglogs).

      Visual Artifacts/Glitches diff --git a/src/chatty/gui/components/help/help.html b/src/chatty/gui/components/help/help.html index fc85d0b81..322c153a9 100644 --- a/src/chatty/gui/components/help/help.html +++ b/src/chatty/gui/components/help/help.html @@ -5,7 +5,7 @@ -

      Chatty (Version: 0.26-b5)

      +

      Chatty (Version: 0.26-b6)

      @@ -1462,6 +1462,7 @@

    • Website: https://chatty.github.io
    • E-Mail: chattyclient@gmail.com
    • Twitter: @ChattyClient
    • +
    • Bluesky: @chattyclient.bsky.social
    • Mastodon: mstdn.social/@chattyclient
    • Discord: Chatty Discord Invite
    • diff --git a/src/chatty/gui/components/settings/HighlighterTester.java b/src/chatty/gui/components/settings/HighlighterTester.java index 34fc4d8ce..422816ac4 100644 --- a/src/chatty/gui/components/settings/HighlighterTester.java +++ b/src/chatty/gui/components/settings/HighlighterTester.java @@ -476,7 +476,7 @@ private void updateInfoText() { testResult.setText("Empty item."); } else if (highlightItem.hasError()) { testResult.setText("Error: "+highlightItem.getError()); - } else if (highlightItem.matchesTest(getTestText(), blacklist)) { + } else if (highlightItem.matchesTextOnly(getTestText(), blacklist)) { testResult.setText("Matched."); } else { String failedReason = highlightItem.getFailedReason(); @@ -523,7 +523,7 @@ private void updateMatches(StyledDocument doc) { Match m = blacklistMatches.get(i); doc.setCharacterAttributes(m.start, m.end - m.start, blacklistAttr, false); } - } else if (blacklistItem.matchesTest(getTestText(), null)) { + } else if (blacklistItem.matchesTextOnly(getTestText(), null)) { doc.setCharacterAttributes(0, doc.getLength(), blacklistAttr, false); } } diff --git a/src/chatty/lang/Strings.properties b/src/chatty/lang/Strings.properties index 5abeb478e..f5cfa45db 100644 --- a/src/chatty/lang/Strings.properties +++ b/src/chatty/lang/Strings.properties @@ -375,6 +375,7 @@ login.access.manageRaids = Start/cancel Raids login.access.managePolls = Manage/view Polls login.access.manageShoutouts = Perform/view Shoutouts login.access.viewFollowers = View followers +login.access.editClips = Create clips login.accessCategory.basic = Basic login.accessCategory.moderation = Moderation login.accessCategory.broadcaster = Broadcaster diff --git a/src/chatty/util/api/Parsing.java b/src/chatty/util/api/Parsing.java index fa2c32196..220b3380d 100644 --- a/src/chatty/util/api/Parsing.java +++ b/src/chatty/util/api/Parsing.java @@ -163,4 +163,16 @@ public static ShieldModeStatus decode(String json, String stream) { } + public static String getClipUrl(String text) { + try { + JSONParser parser = new JSONParser(); + JSONObject root = (JSONObject) parser.parse(text); + JSONObject data = (JSONObject) ((JSONArray) root.get("data")).get(0); + return (String) data.get("edit_url"); + } catch (Exception ex) { + LOGGER.warning("Error getting clip url: " + ex); + } + return null; + } + } diff --git a/src/chatty/util/api/Requests.java b/src/chatty/util/api/Requests.java index 22a9b3753..24fc4a6c7 100644 --- a/src/chatty/util/api/Requests.java +++ b/src/chatty/util/api/Requests.java @@ -468,6 +468,34 @@ public void createStreamMarker(String userId, String description, String token, }); } + public void createClip(String userId, Consumer listener) { + String url = makeUrl("https://api.twitch.tv/helix/clips", + "broadcaster_id", userId); + newApi.add(url, "POST", api.defaultToken, r -> { + if (r.responseCode == 202) { + String clipUrl = Parsing.getClipUrl(r.text); + if (clipUrl != null) { + listener.accept("Edit clip: "+clipUrl); + } + else { + listener.accept("Error creating clip"); + } + } + else if (r.responseCode == 401) { + listener.accept("Creating clip failed: Check access under 'Main - Account'"); + } + else { + String errorMsg = getErrorMessage(r.text); + if (errorMsg != null) { + listener.accept(errorMsg); + } + else { + listener.accept("Creating clip failed"); + } + } + }); + } + public void sendAnnouncement(String streamId, String message, String color) { String url = String.format("https://api.twitch.tv/helix/chat/announcements?broadcaster_id=%s&moderator_id=%s", streamId, api.localUserId); diff --git a/src/chatty/util/api/TokenInfo.java b/src/chatty/util/api/TokenInfo.java index 14b94bc7e..ae2a95ec5 100644 --- a/src/chatty/util/api/TokenInfo.java +++ b/src/chatty/util/api/TokenInfo.java @@ -26,7 +26,8 @@ public enum ScopeCategory { Scope.WHISPER_MANAGE, Scope.MANAGE_COLOR, Scope.POINTS, - Scope.FOLLOWS + Scope.FOLLOWS, + Scope.CLIPS_EDIT ), MODERATION("moderation", Scope.MANAGE_CHAT, @@ -104,7 +105,8 @@ public enum Scope { MANAGE_COLOR("user:manage:chat_color", "manageColor"), MANAGE_RAIDS("channel:manage:raids", "manageRaids"), MANAGE_POLLS("channel:manage:polls", "managePolls"), - MANAGE_SHOUTOUTS("moderator:manage:shoutouts", "manageShoutouts"); + MANAGE_SHOUTOUTS("moderator:manage:shoutouts", "manageShoutouts"), + CLIPS_EDIT("clips:edit", "editClips"); public String scope; public String label; diff --git a/src/chatty/util/api/TwitchApi.java b/src/chatty/util/api/TwitchApi.java index 4ff8cb8a0..f9ae198fa 100644 --- a/src/chatty/util/api/TwitchApi.java +++ b/src/chatty/util/api/TwitchApi.java @@ -530,6 +530,16 @@ public void createStreamMarker(String stream, String description, StreamMarkerRe } }, stream); } + + public void createClip(String stream, Consumer listener) { + userIDs.getUserIDsAsap(r -> { + if (r.hasError()) { + listener.accept("Failed to resolve channel id"); + } else { + requests.createClip(r.getId(stream), listener); + } + }, stream); + } public static String[] ANNOUNCEMENT_COLORS = new String[]{ "", "primary", "blue", "green", "orange", "purple" diff --git a/test/chatty/gui/HighlighterTest.java b/test/chatty/gui/HighlighterTest.java index 6c5199767..6f67a10e0 100644 --- a/test/chatty/gui/HighlighterTest.java +++ b/test/chatty/gui/HighlighterTest.java @@ -18,6 +18,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.TimeUnit; import org.junit.BeforeClass; import org.junit.Test; import static org.junit.Assert.*; @@ -60,6 +61,16 @@ private void update(String... items) { highlighter.update(Arrays.asList(items)); } + private void updateTrue(User user, String text, String... items) { + update(items); + assertTrue(highlighter.check(user, text)); + } + + private void updateFalse(User user, String text, String... items) { + update(items); + assertFalse(highlighter.check(user, text)); + } + private void updateBlacklist(String... items) { highlighter.updateBlacklist(Arrays.asList(items)); } @@ -1558,4 +1569,75 @@ public void testAfterban() { assertFalse(highlighter.check(banUser, "abc")); } + @Test + public void testMsgs() { + User msgsUser = new User("testUser", Room.createRegular("#testChannel")); + + updateFalse(msgsUser, "", "msgs:!live"); + updateBlacklist(""); + + msgsUser.addMessage("abc", false, "abc", System.currentTimeMillis() - TimeUnit.DAYS.toMillis(10)); + assertFalse(highlighter.check(msgsUser, "abc")); + + msgsUser.addMessage("!live", false, "abc", System.currentTimeMillis() - TimeUnit.DAYS.toMillis(8)); + assertTrue(highlighter.check(msgsUser, "abc")); + + updateTrue(msgsUser, "", "msgs:\"mlimit:1 !live\""); + + msgsUser.addMessage("123", false, "abc", System.currentTimeMillis() - TimeUnit.DAYS.toMillis(5)); + assertFalse(highlighter.check(msgsUser, "abc")); + + updateTrue(msgsUser, "", "msgs:\"mlimit:2 !live\""); + updateFalse(msgsUser, "", "msgs:\"mlimit:2 mreq:2 !live"); + updateTrue(msgsUser, "", "msgs:\"mlimit:2 mreq:1 !live"); + updateFalse(msgsUser, "", "msgs:\"mlimit:2 mreq:2 !live"); + updateTrue(msgsUser, "", "msgs:\"mlimit:2 mreq:1 !live"); + updateTrue(msgsUser, "", "msgs:\"mlimit:2 mreq:1 e"); + updateFalse(msgsUser, "", "msgs:\"mlimit:2 mreq:1 start:e"); + updateTrue(msgsUser, "", "msgs:\"mlimit:2 mreq:1 !start:e"); + + updateTrue(msgsUser, "", "msgs:mreq:1"); + updateTrue(msgsUser, "", "msgs:mreq:2"); + updateTrue(msgsUser, "", "msgs:mreq:3"); + updateFalse(msgsUser, "", "msgs:mreq:4"); + updateTrue(msgsUser, "", "msgs:mreq:3,mreq:4"); + updateFalse(msgsUser, "", "msgs:mreq:4,mreq:5"); + + updateTrue(msgsUser, "", "msgs:abc,!live"); + updateTrue(msgsUser, "", "msgs:fewafagwaef,!live"); + updateTrue(msgsUser, "", "msgs:\"mlimit:1 !live\",\"mlimit:2 !live\""); + + updateFalse(msgsUser, "", "!msgs:abc,!live"); + updateFalse(msgsUser, "", "!msgs:mreq:1"); + + updateFalse(msgsUser, "", "msgs:\"mreq:1 mtime:1d\""); + updateTrue(msgsUser, "", "msgs:\"mreq:1 mtime:6d\""); + updateFalse(msgsUser, "", "msgs:\"mreq:2 mtime:6d\""); + updateTrue(msgsUser, "", "msgs:\"mreq:2 mtime:9d\""); + + updateTrue(msgsUser, "", "msgs:\"mtime:6d 123\""); + updateTrue(msgsUser, "", "msgs:\"mtime:<6d 123\""); + updateFalse(msgsUser, "", "msgs:\"mtime:>6d 123\""); + updateFalse(msgsUser, "", "msgs:\"mtime:6d !live\""); + updateFalse(msgsUser, "", "msgs:\"mtime:<6d !live\""); + updateTrue(msgsUser, "", "msgs:\"mtime:>6d !live\""); + updateFalse(msgsUser, "", "msgs:\"mtime:6d !live2\""); + updateFalse(msgsUser, "", "msgs:\"mtime:<6d !live2\""); + updateFalse(msgsUser, "", "msgs:\"mtime:>6d !live2\""); + updateFalse(msgsUser, "", "msgs:\"mtime:7d !live\""); + updateFalse(msgsUser, "", "msgs:\"mtime:<7d !live\""); + updateTrue(msgsUser, "", "msgs:\"mtime:9d !live\""); + + updateTrue(msgsUser, "!live", "msgs:\"mtype:outer\" start:!live"); + updateFalse(msgsUser, "!bot", "msgs:\"mtype:outer\" start:!bot"); + + updateTrue(msgsUser, "!bot", "msgs:\"mtype:outer feafawefewafawefawefwaf\""); + updateFalse(msgsUser, "!bot", "msgs:\"mtype:abc feafawefewafawefawefwaf\""); + + updateTrue(msgsUser, "!bot", "msgs:\"abc\",12345"); + updateTrue(msgsUser, "!bot", "msgs:\"mtime:6d !live\",\"mtime:11d abc\""); + updateTrue(msgsUser, "!bot", "msgs:\"mtype:outer\",\"abc\" !bot"); + updateFalse(msgsUser, "!bot", "msgs:\"mtype:outer\",\"abc2\" !bot"); + } + }