Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/ws chat #233

Merged
merged 5 commits into from
Jul 21, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion README.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ Examples can be found in link:https://github.com/RakSrinaNa/ChannelPointsMiner/b

Account settings define all the settings for a Twitch account that will be used to mine points.

[cols="1,3,1"]
[cols="1,3a,1"]
|===
|Name |Description |Default value

Expand Down Expand Up @@ -176,6 +176,13 @@ NOTE: Only <<streamer_settings,streamer settings>> are reloaded, not the <<globa
|analytics
|<<analytics_settings,Analytics settings>>.
|

|chatMode
|Define what method is used to connect to Twitch chat.

* WS: Using WebSocket method (used on Twitch website).
* IRC: Using IRC protocol.
|WS
|===

=== Streamer directories [[streamer_directories]]
Expand Down
2 changes: 2 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ kitteh-irc-version = "8.0.0"
hikari-cp-version = "5.0.1"
mariadb-version = "3.0.6"
sqlite-version = "3.36.0.3"
rerunner-jupiter-version = "2.1.6"

shadow-version = "7.1.2"
names-version = "0.42.0"
Expand Down Expand Up @@ -57,6 +58,7 @@ mockito-junit = { group = "org.mockito", name = "mockito-junit-jupiter", version
awaitility = { group = "org.awaitility", name = "awaitility", version.ref = "awaitility-version" }
json-unit = { group = "net.javacrumbs.json-unit", name = "json-unit", version.ref = "json-unit-version" }
json-unit-assertj = { group = "net.javacrumbs.json-unit", name = "json-unit-assertj", version.ref = "json-unit-version" }
rerunnerJupiter = { group = "io.github.artsok", name = "rerunner-jupiter", version.ref = "rerunner-jupiter-version" }

[bundles]
jackson = ["jackson-core", "jackson-annotations", "jackson-databind"]
Expand Down
Binary file modified gradle/wrapper/gradle-wrapper.jar
Binary file not shown.
2 changes: 1 addition & 1 deletion gradle/wrapper/gradle-wrapper.properties
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
10 changes: 5 additions & 5 deletions gradlew
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#!/bin/sh

#
# Copyright 2015-2021 the original authors.
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -32,10 +32,10 @@
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions $var�, �${var}�, �${var:-default}�, �${var+SET},
# ${var#prefix}�, �${var%suffix}, and $( cmd );
# * compound commands having a testable exit status, especially case;
# * various built-in commands including command�, �set, and ulimit.
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
Expand Down
1 change: 1 addition & 0 deletions miner/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ dependencies {
testImplementation(libs.awaitility)
testImplementation(libs.unirestMocks)
testImplementation(libs.bundles.jsonUnit)
testImplementation(libs.rerunnerJupiter)
}

tasks {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package fr.raksrinana.channelpointsminer.miner.api.chat;

import org.jetbrains.annotations.NotNull;

public interface ITwitchChatClient extends AutoCloseable{
void join(@NotNull String channel);

void leave(@NotNull String channel);

@Override
void close();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package fr.raksrinana.channelpointsminer.miner.api.chat;

import fr.raksrinana.channelpointsminer.miner.api.chat.irc.TwitchIrcChatClient;
import fr.raksrinana.channelpointsminer.miner.api.chat.ws.TwitchChatWebSocketPool;
import fr.raksrinana.channelpointsminer.miner.api.passport.TwitchLogin;
import fr.raksrinana.channelpointsminer.miner.config.ChatMode;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
import org.jetbrains.annotations.NotNull;

@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class TwitchChatFactory{
@NotNull
public static ITwitchChatClient createChat(@NotNull ChatMode chatMode, @NotNull TwitchLogin twitchLogin){
return switch(chatMode){
case IRC -> createIrcChat(twitchLogin);
case WS -> createWsChat(twitchLogin);
};
}

@NotNull
private static ITwitchChatClient createIrcChat(@NotNull TwitchLogin twitchLogin){
return new TwitchIrcChatClient(twitchLogin);
}

@NotNull
private static ITwitchChatClient createWsChat(@NotNull TwitchLogin twitchLogin){
return new TwitchChatWebSocketPool(Integer.MAX_VALUE, twitchLogin);
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package fr.raksrinana.channelpointsminer.miner.irc;
package fr.raksrinana.channelpointsminer.miner.api.chat.irc;

import fr.raksrinana.channelpointsminer.miner.api.chat.ITwitchChatClient;
import fr.raksrinana.channelpointsminer.miner.api.passport.TwitchLogin;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
Expand All @@ -12,14 +13,15 @@

@RequiredArgsConstructor
@Log4j2
public class TwitchIrcClient implements AutoCloseable{
public class TwitchIrcChatClient implements ITwitchChatClient{

@NotNull
private final TwitchLogin twitchLogin;

@Nullable
private Client ircClient;

@Override
public void join(@NotNull String channel){
var client = getIrcClient();
var ircChannelName = "#%s".formatted(channel.toLowerCase(Locale.ROOT));
Expand All @@ -36,18 +38,19 @@ private synchronized Client getIrcClient(){
if(Objects.isNull(ircClient)){
log.info("Creating new Twitch IRC client");

ircClient = TwitchIrcFactory.createClient(twitchLogin);
ircClient = TwitchIrcFactory.createIrcClient(twitchLogin);
ircClient.connect();
ircClient.setExceptionListener(e -> log.error("Error from irc", e));

ircClient.getEventManager().registerEventListener(TwitchIrcFactory.createListener(twitchLogin.getUsername()));
ircClient.getEventManager().registerEventListener(TwitchIrcFactory.createIrcListener(twitchLogin.getUsername()));

log.info("IRC Client created");
}

return ircClient;
}

@Override
public void leave(@NotNull String channel){
if(Objects.isNull(ircClient)){
log.debug("Didn't leave irc channel #{} as no connection has been made", channel);
Expand All @@ -64,6 +67,7 @@ public void leave(@NotNull String channel){
ircClient.removeChannel(ircChannelName);
}

@Override
public void close(){
Optional.ofNullable(ircClient).ifPresent(Client::shutdown);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package fr.raksrinana.channelpointsminer.miner.irc;
package fr.raksrinana.channelpointsminer.miner.api.chat.irc;

import fr.raksrinana.channelpointsminer.miner.log.LogContext;
import lombok.RequiredArgsConstructor;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package fr.raksrinana.channelpointsminer.miner.irc;
package fr.raksrinana.channelpointsminer.miner.api.chat.irc;

import fr.raksrinana.channelpointsminer.miner.api.passport.TwitchLogin;
import lombok.AccessLevel;
Expand All @@ -14,17 +14,12 @@ public class TwitchIrcFactory{
private static final String TWITCH_IRC_HOST = "irc.chat.twitch.tv";

@NotNull
public static TwitchIrcClient create(@NotNull TwitchLogin twitchLogin){
return new TwitchIrcClient(twitchLogin);
public static Client createIrcClient(@NotNull TwitchLogin twitchLogin){
return createIrcClient(twitchLogin.getUsername(), "oauth:%s".formatted(twitchLogin.getAccessToken()));
}

@NotNull
public static Client createClient(@NotNull TwitchLogin twitchLogin){
return createClient(twitchLogin.getUsername(), "oauth:%s".formatted(twitchLogin.getAccessToken()));
}

@NotNull
private static Client createClient(@NotNull String username, @Nullable String password){
private static Client createIrcClient(@NotNull String username, @Nullable String password){
var client = Client.builder()
.server()
.host(TWITCH_IRC_HOST).port(443, SECURE)
Expand All @@ -36,7 +31,7 @@ private static Client createClient(@NotNull String username, @Nullable String pa
}

@NotNull
public static TwitchIrcEventListener createListener(@NotNull String accountName){
public static TwitchIrcEventListener createIrcListener(@NotNull String accountName){
return new TwitchIrcEventListener(accountName);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package fr.raksrinana.channelpointsminer.miner.api.chat.ws;

import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

public interface ITwitchChatWebSocketListener{
void onWebSocketClosed(@NotNull TwitchChatWebSocketClient client, int code, @Nullable String reason, boolean remote);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
package fr.raksrinana.channelpointsminer.miner.api.chat.ws;

import fr.raksrinana.channelpointsminer.miner.api.chat.ITwitchChatClient;
import fr.raksrinana.channelpointsminer.miner.api.passport.TwitchLogin;
import fr.raksrinana.channelpointsminer.miner.factory.TimeFactory;
import fr.raksrinana.channelpointsminer.miner.log.LogContext;
import lombok.Getter;
import lombok.extern.log4j.Log4j2;
import org.java_websocket.WebSocket;
import org.java_websocket.client.WebSocketClient;
import org.java_websocket.framing.Framedata;
import org.java_websocket.handshake.ServerHandshake;
import org.jetbrains.annotations.NotNull;
import java.net.URI;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;

@Log4j2
public class TwitchChatWebSocketClient extends WebSocketClient implements ITwitchChatClient{
@Getter
private final Set<String> channels;
private final List<ITwitchChatWebSocketListener> listeners;
@Getter
private final String uuid;
private final TwitchLogin twitchLogin;

@Getter
private Instant lastPing;

public TwitchChatWebSocketClient(@NotNull URI uri, @NotNull TwitchLogin twitchLogin){
super(uri);
this.twitchLogin = twitchLogin;
uuid = UUID.randomUUID().toString();

setConnectionLostTimeout(0);
channels = new HashSet<>();
listeners = new ArrayList<>();
lastPing = Instant.EPOCH;
}

@Override
public void onOpen(ServerHandshake serverHandshake){
try(var ignored = LogContext.empty().withSocketId(uuid)){
log.info("IRC WebSocket opened");
onPing();
sendMessage("CAP REQ :twitch.tv/tags twitch.tv/commands");
send("PASS oauth:%s".formatted(twitchLogin.getAccessToken()));
sendMessage("NICK %s".formatted(twitchLogin.getUsername().toLowerCase()));
}
}

@Override
public void onMessage(String messageStr){
try(var logContext = LogContext.empty().withSocketId(uuid)){
log.trace("Received IRC Websocket message: {}", messageStr.strip());
}
catch(Exception e){
log.error("Failed to handle IRC WebSocket message {}", messageStr, e);
}
}

@Override
public void onClose(int code, String reason, boolean remote){
try(var ignored = LogContext.empty().withSocketId(uuid)){
log.info("IRC WebSocket closed with code {}, from host {}, reason {}", code, remote, reason);
listeners.forEach(l -> l.onWebSocketClosed(this, code, reason, remote));
}
}

@Override
public void onError(Exception e){
log.error("Error from IRC WebSocket", e);
}

private void onPing(){
lastPing = TimeFactory.now();
log.debug("Received IRC Ping request");
}

private void sendMessage(@NotNull String message){
log.trace("Sending IRC message {}", message);
send(message);
}

@Override
public void onWebsocketPing(WebSocket conn, Framedata f){
onPing();
sendMessage("PONG");
}

@Override
public void join(@NotNull String channel){
try(var ignored = LogContext.empty().withSocketId(uuid)){
if(channels.add(channel)){
sendMessage("JOIN #" + channel);
}
}
}

@Override
public void leave(@NotNull String channel){
try(var ignored = LogContext.empty().withSocketId(uuid)){
sendMessage("PART #" + channel);
channels.remove(channel);
}
}

public boolean isChannelJoined(@NotNull String channel){
return channels.contains(channel);
}

public void addListener(ITwitchChatWebSocketListener listener){
listeners.add(listener);
}

public long getChannelCount(){
return getChannels().size();
}
}
Loading