Merge pull request #5276 from Adam-/twitch3

Twitch chat plugin
This commit is contained in:
Adam
2018-10-22 21:57:21 -04:00
committed by GitHub
8 changed files with 731 additions and 5 deletions

View File

@@ -44,4 +44,6 @@ public @interface ConfigItem
boolean hidden() default false;
String warning() default "";
boolean secret() default false;
}

View File

@@ -56,6 +56,7 @@ import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JPasswordField;
import javax.swing.JScrollPane;
import javax.swing.JSpinner;
import javax.swing.JTextArea;
@@ -66,6 +67,7 @@ import javax.swing.border.EmptyBorder;
import javax.swing.event.ChangeListener;
import javax.swing.event.DocumentEvent;
import javax.swing.event.DocumentListener;
import javax.swing.text.JTextComponent;
import lombok.extern.slf4j.Slf4j;
import net.runelite.client.config.ChatColorConfig;
import net.runelite.client.config.Config;
@@ -351,9 +353,20 @@ public class ConfigPanel extends PluginPanel
if (cid.getType() == String.class)
{
JTextArea textField = new JTextArea();
textField.setLineWrap(true);
textField.setWrapStyleWord(true);
JTextComponent textField;
if (cid.getItem().secret())
{
textField = new JPasswordField();
}
else
{
final JTextArea textArea = new JTextArea();
textArea.setLineWrap(true);
textArea.setWrapStyleWord(true);
textField = textArea;
}
textField.setBorder(BorderFactory.createEmptyBorder(5, 5, 5, 5));
textField.setText(configManager.getConfiguration(cd.getGroup().value(), cid.getItem().keyName()));
@@ -548,9 +561,9 @@ public class ConfigPanel extends PluginPanel
JSpinner spinner = (JSpinner) component;
configManager.setConfiguration(cd.getGroup().value(), cid.getItem().keyName(), "" + spinner.getValue());
}
else if (component instanceof JTextArea)
else if (component instanceof JTextComponent)
{
JTextArea textField = (JTextArea) component;
JTextComponent textField = (JTextComponent) component;
configManager.setConfiguration(cd.getGroup().value(), cid.getItem().keyName(), textField.getText());
}
else if (component instanceof JColorChooser)

View File

@@ -0,0 +1,58 @@
/*
* Copyright (c) 2018, Adam <Adam@sigterm.info>
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
* ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
package net.runelite.client.plugins.twitch;
import net.runelite.client.config.Config;
import net.runelite.client.config.ConfigGroup;
import net.runelite.client.config.ConfigItem;
@ConfigGroup("twitch")
public interface TwitchConfig extends Config
{
@ConfigItem(
keyName = "username",
name = "Username",
description = "Twitch Username",
position = 0
)
String username();
@ConfigItem(
keyName = "oauth",
name = "OAuth Token",
description = "Enter your OAuth token here. This can be found at http://www.twitchapps.com/tmi/",
secret = true,
position = 1
)
String oauthToken();
@ConfigItem(
keyName = "channel",
name = "Channel",
description = "Username of Twitch chat to join",
position = 2
)
String channel();
}

View File

@@ -0,0 +1,227 @@
/*
* Copyright (c) 2018, Adam <Adam@sigterm.info>
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
* ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
package net.runelite.client.plugins.twitch;
import com.google.common.eventbus.Subscribe;
import com.google.inject.Provides;
import java.time.temporal.ChronoUnit;
import java.util.Map;
import javax.inject.Inject;
import lombok.extern.slf4j.Slf4j;
import net.runelite.api.ChatMessageType;
import net.runelite.api.Client;
import net.runelite.api.GameState;
import net.runelite.api.events.ConfigChanged;
import net.runelite.client.chat.ChatMessageBuilder;
import net.runelite.client.chat.ChatMessageManager;
import net.runelite.client.chat.ChatboxInputListener;
import net.runelite.client.chat.CommandManager;
import net.runelite.client.chat.QueuedMessage;
import net.runelite.client.config.ConfigManager;
import net.runelite.client.events.ChatboxInput;
import net.runelite.client.events.PrivateMessageInput;
import net.runelite.client.plugins.Plugin;
import net.runelite.client.plugins.PluginDescriptor;
import net.runelite.client.plugins.twitch.irc.TwitchIRCClient;
import net.runelite.client.plugins.twitch.irc.TwitchListener;
import net.runelite.client.task.Schedule;
@PluginDescriptor(
name = "Twitch",
description = "Integrates Twitch chat",
enabledByDefault = false
)
@Slf4j
public class TwitchPlugin extends Plugin implements TwitchListener, ChatboxInputListener
{
@Inject
private TwitchConfig twitchConfig;
@Inject
private Client client;
@Inject
private ChatMessageManager chatMessageManager;
@Inject
private CommandManager commandManager;
private TwitchIRCClient twitchIRCClient;
@Override
protected void startUp()
{
connect();
commandManager.register(this);
}
@Override
protected void shutDown()
{
if (twitchIRCClient != null)
{
twitchIRCClient.close();
twitchIRCClient = null;
}
commandManager.unregister(this);
}
@Provides
TwitchConfig provideConfig(ConfigManager configManager)
{
return configManager.getConfig(TwitchConfig.class);
}
private void connect()
{
if (twitchConfig.username() != null
&& twitchConfig.oauthToken() != null
&& twitchConfig.channel() != null)
{
String channel = twitchConfig.channel();
if (!channel.startsWith("#"))
{
channel = "#" + channel;
}
twitchIRCClient = new TwitchIRCClient(
this,
twitchConfig.username(),
twitchConfig.oauthToken(),
channel
);
twitchIRCClient.start();
}
}
@Schedule(period = 30, unit = ChronoUnit.SECONDS, asynchronous = true)
public void checkClient()
{
if (twitchIRCClient != null)
{
if (twitchIRCClient.isConnected())
{
twitchIRCClient.pingCheck();
}
if (!twitchIRCClient.isConnected())
{
log.debug("Reconnecting...");
connect();
}
}
}
@Subscribe
public void onConfigChanged(ConfigChanged configChanged)
{
if (!configChanged.getGroup().equals("twitch"))
{
return;
}
if (twitchIRCClient != null)
{
twitchIRCClient.close();
twitchIRCClient = null;
}
connect();
}
private void addChatMessage(String sender, String message)
{
String chatMessage = new ChatMessageBuilder()
.append(message)
.build();
chatMessageManager.queue(QueuedMessage.builder()
.type(ChatMessageType.CLANCHAT)
.sender("Twitch")
.name(sender)
.runeLiteFormattedMessage(chatMessage)
.build());
}
@Override
public void privmsg(Map<String, String> tags, String message)
{
if (client.getGameState() != GameState.LOGGED_IN)
{
return;
}
String displayName = tags.get("display-name");
addChatMessage(displayName, message);
}
@Override
public void roomstate(Map<String, String> tags)
{
log.debug("Room state: {}", tags);
}
@Override
public void usernotice(Map<String, String> tags, String message)
{
log.debug("Usernotice tags: {} message: {}", tags, message);
if (client.getGameState() != GameState.LOGGED_IN)
{
return;
}
String sysmsg = tags.get("system-msg");
addChatMessage("[System]", sysmsg);
}
@Override
public boolean onChatboxInput(ChatboxInput chatboxInput)
{
String message = chatboxInput.getValue();
if (message.startsWith("//"))
{
message = message.substring(2);
if (message.isEmpty() || twitchIRCClient == null)
{
return true;
}
twitchIRCClient.privmsg(message);
addChatMessage(twitchConfig.username(), message);
return true;
}
return false;
}
@Override
public boolean onPrivateMessageInput(PrivateMessageInput privateMessageInput)
{
return false;
}
}

View File

@@ -0,0 +1,108 @@
/*
* Copyright (c) 2018, Adam <Adam@sigterm.info>
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
* ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
package net.runelite.client.plugins.twitch.irc;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import lombok.Getter;
@Getter
class Message
{
private final Map<String, String> tags = new HashMap<>();
private String source;
private String command;
private String[] arguments;
public static Message parse(String in)
{
Message message = new Message();
if (in.startsWith("@"))
{
String[] tags = in.substring(1)
.split(";");
for (String tag : tags)
{
int eq = tag.indexOf('=');
if (eq == -1) continue;
String key = tag.substring(0, eq);
String value = tag.substring(eq + 1)
.replace("\\:", ";")
.replace("\\s", " ")
.replace("\\\\", "\\")
.replace("\\r", "\r")
.replace("\\n", "\n");
message.tags.put(key, value);
}
int sp = in.indexOf(' ');
in = in.substring(sp + 1);
}
if (in.startsWith(":"))
{
int sp = in.indexOf(' ');
message.source = in.substring(1, sp);
in = in.substring(sp + 1);
}
int sp = in.indexOf(' ');
if (sp == -1)
{
message.command = in;
message.arguments = new String[0];
return message;
}
message.command = in.substring(0, sp);
String args = in.substring(sp + 1);
List<String> argList = new ArrayList<>();
do
{
String arg;
if (args.startsWith(":"))
{
arg = args.substring(1);
sp = -1;
}
else
{
sp = args.indexOf(' ');
arg = sp == -1 ? args : args.substring(0, sp);
}
args = args.substring(sp + 1);
argList.add(arg);
} while (sp != -1);
message.arguments = argList.toArray(new String[0]);
return message;
}
}

View File

@@ -0,0 +1,234 @@
/*
* Copyright (c) 2018, Adam <Adam@sigterm.info>
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
* ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
package net.runelite.client.plugins.twitch.irc;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.Socket;
import javax.net.SocketFactory;
import javax.net.ssl.SSLSocketFactory;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class TwitchIRCClient extends Thread implements AutoCloseable
{
private static final String HOST = "irc.chat.twitch.tv";
private static final int PORT = 6697;
private static final int READ_TIMEOUT = 60000; // ms
private static final int PING_TIMEOUT = 30000; // ms
private final TwitchListener twitchListener;
private final String username, password;
private final String channel;
private Socket socket;
private BufferedReader in;
private PrintWriter out;
private long last;
private boolean pingSent;
public TwitchIRCClient(TwitchListener twitchListener, String username, String password, String channel)
{
setName("Twitch");
this.twitchListener = twitchListener;
this.username = username;
this.password = password;
this.channel = channel;
}
@Override
public void close()
{
try
{
socket.close();
}
catch (IOException ex)
{
log.warn("error closing socket", ex);
}
in = null;
out = null;
}
@Override
public void run()
{
try
{
SocketFactory socketFactory = SSLSocketFactory.getDefault();
socket = socketFactory.createSocket(HOST, PORT);
socket.setSoTimeout(READ_TIMEOUT);
in = new BufferedReader(new InputStreamReader(socket.getInputStream()));
out = new PrintWriter(socket.getOutputStream());
}
catch (IOException ex)
{
log.warn("unable to setup irc client", ex);
return;
}
register(username, password);
join(channel);
try
{
String line;
while ((line = read()) != null)
{
log.debug("<- {}", line);
last = System.currentTimeMillis();
pingSent = false;
Message message = Message.parse(line);
switch (message.getCommand())
{
case "PING":
send("PONG", message.getArguments()[0]);
break;
case "PRIVMSG":
twitchListener.privmsg(message.getTags(),
message.getArguments()[1]);
break;
case "ROOMSTATE":
twitchListener.roomstate(message.getTags());
break;
case "USERNOTICE":
twitchListener.usernotice(message.getTags(),
message.getArguments().length > 0 ? message.getArguments()[0] : null);
break;
}
}
}
catch (IOException ex)
{
log.debug("error in twitch irc client", ex);
}
finally
{
try
{
socket.close();
}
catch (IOException e)
{
log.warn(null, e);
}
}
}
public boolean isConnected()
{
return socket != null && socket.isConnected() && !socket.isClosed();
}
public void pingCheck()
{
if (out == null)
{
// client is not connected yet
return;
}
if (!pingSent && System.currentTimeMillis() - last >= PING_TIMEOUT)
{
ping("twitch");
pingSent = true;
}
else if (pingSent)
{
log.debug("Ping timeout, disconnecting.");
close();
}
}
private void register(String username, String oauth)
{
send("CAP", "REQ", "twitch.tv/commands twitch.tv/tags");
send("PASS", oauth);
send("NICK", username);
}
private void join(String channel)
{
send("JOIN", channel);
}
private void ping(String destination)
{
send("PING", destination);
}
public void privmsg(String message)
{
send("PRIVMSG", channel, message);
}
private void send(String command, String... args)
{
StringBuilder stringBuilder = new StringBuilder();
stringBuilder.append(command);
for (int i = 0; i < args.length; ++i)
{
stringBuilder.append(' ');
if (i + 1 == args.length)
{
stringBuilder.append(':');
}
stringBuilder.append(args[i]);
}
log.debug("-> {}", stringBuilder.toString());
stringBuilder.append("\r\n");
out.write(stringBuilder.toString());
out.flush();
}
private String read() throws IOException
{
String line = in.readLine();
if (line == null)
{
return null;
}
int len = line.length();
while (len > 0 && (line.charAt(len - 1) == '\r' || line.charAt(len - 1) == '\n'))
{
--len;
}
return line.substring(0, len);
}
}

View File

@@ -0,0 +1,36 @@
/*
* Copyright (c) 2018, Adam <Adam@sigterm.info>
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
* ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
package net.runelite.client.plugins.twitch.irc;
import java.util.Map;
public interface TwitchListener
{
void privmsg(Map<String, String> tags, String message);
void roomstate(Map<String, String> tags);
void usernotice(Map<String, String> tags, String message);
}

View File

@@ -0,0 +1,48 @@
/*
* Copyright (c) 2018, Adam <Adam@sigterm.info>
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
* ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
package net.runelite.client.plugins.twitch.irc;
import java.util.Map;
import static org.junit.Assert.assertEquals;
import org.junit.Test;
public class MessageTest
{
@Test
public void testParse()
{
Message message = Message.parse("@badges=subscriber/0;color=;display-name=kappa_kid_;emotes=;id=6539b42a-e945-4a83-a5b7-018149ca9fa7;mod=0;room-id=27107346;subscriber=1;tmi-sent-ts=1535926830652;turbo=0;user-id=33390095;user-type= :kappa_kid_!kappa_kid_@kappa_kid_.tmi.twitch.tv PRIVMSG #b0aty :how do u add charges to that zeah book?");
Map<String, String> messageTags = message.getTags();
assertEquals("subscriber/0", messageTags.get("badges"));
assertEquals("kappa_kid_!kappa_kid_@kappa_kid_.tmi.twitch.tv", message.getSource());
assertEquals("PRIVMSG", message.getCommand());
assertEquals("#b0aty", message.getArguments()[0]);
assertEquals("how do u add charges to that zeah book?", message.getArguments()[1]);
message = Message.parse("@badges=moderator/1,subscriber/12,bits/10000;color=#008000;display-name=Am_Sephiroth;emotes=;id=7d516b7c-de7a-4c8b-ad23-d8880b55d46b;login=am_sephiroth;mod=1;msg-id=subgift;msg-param-months=8;msg-param-recipient-display-name=IntRS;msg-param-recipient-id=189672346;msg-param-recipient-user-name=intrs;msg-param-sender-count=215;msg-param-sub-plan-name=Sick\\sNerd\\sSubscription\\s;msg-param-sub-plan=1000;room-id=49408183;subscriber=1;system-msg=Am_Sephiroth\\sgifted\\sa\\sTier\\s1\\ssub\\sto\\sIntRS!\\sThey\\shave\\sgiven\\s215\\sGift\\sSubs\\sin\\sthe\\schannel!;tmi-sent-ts=1535980032939;turbo=0;user-id=69539403;user-type=mod :tmi.twitch.tv USERNOTICE #sick_nerd");
messageTags = message.getTags();
assertEquals("Am_Sephiroth gifted a Tier 1 sub to IntRS! They have given 215 Gift Subs in the channel!", messageTags.get("system-msg"));
}
}