First upload
First upload working on it still having trouble with issues not showing the username but this is a start
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
run/
|
||||||
|
*.iml
|
||||||
|
.idea/
|
||||||
@@ -1,2 +1,41 @@
|
|||||||
# stream-chat-mod
|
# Stream Chat
|
||||||
|
|
||||||
|
A client-side Fabric mod that displays Twitch, YouTube, and TikTok live-stream
|
||||||
|
events in Minecraft. Twitch and YouTube events are received from Streamer.bot;
|
||||||
|
TikTok events are received from TikFinity.
|
||||||
|
|
||||||
|
## Current status
|
||||||
|
|
||||||
|
The project is at its initial implementation stage. The default and currently
|
||||||
|
verified build target is Minecraft 26.1.2 with Fabric. A separate legacy
|
||||||
|
client source set is retained for Minecraft 1.20.1.
|
||||||
|
|
||||||
|
## Initial setup
|
||||||
|
|
||||||
|
1. Install Fabric Loader 0.20.1 and Fabric API 0.155.3 for Minecraft 26.1.2.
|
||||||
|
2. Install Mod Menu to open the mod's settings screen from the Mods menu.
|
||||||
|
3. Configure the Streamer.bot and TikFinity WebSocket addresses in
|
||||||
|
`config/stream-chat.json` or in the in-game settings.
|
||||||
|
4. Start Streamer.bot/TikFinity and launch Minecraft. Enabled integrations
|
||||||
|
connect automatically; use the configured keybind or settings screen to
|
||||||
|
toggle them.
|
||||||
|
|
||||||
|
The Streamer.bot password, when configured, is stored as plain text in the
|
||||||
|
local Minecraft config file. Do not share that file or diagnostic logs without
|
||||||
|
checking them for private information.
|
||||||
|
|
||||||
|
The mod is client-only. It does not send stream events or chat messages to the
|
||||||
|
Minecraft server.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
Use Java 25 for the Minecraft 26.1.2 target. Build with Gradle:
|
||||||
|
|
||||||
|
```text
|
||||||
|
gradlew build
|
||||||
|
```
|
||||||
|
|
||||||
|
The Minecraft version, Fabric Loader, Fabric API, Mod Menu, Loom, and Java
|
||||||
|
versions are declared in `gradle.properties`. Select matching versions there
|
||||||
|
before building a different Minecraft target. The legacy 1.20.1 source set
|
||||||
|
was verified separately; other releases need their own compatibility checks.
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
plugins {
|
||||||
|
id "fabric-loom" version "${legacy_loom_version}" apply false
|
||||||
|
id "net.fabricmc.fabric-loom" version "${loom_version}" apply false
|
||||||
|
}
|
||||||
|
|
||||||
|
apply plugin: project.minecraft_version.startsWith("26.") ? "net.fabricmc.fabric-loom" : "fabric-loom"
|
||||||
|
|
||||||
|
version = project.mod_version
|
||||||
|
group = project.maven_group
|
||||||
|
|
||||||
|
base {
|
||||||
|
archivesName = "${project.archives_base_name}-${project.minecraft_version}"
|
||||||
|
}
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
maven { url = "https://maven.terraformersmc.com/releases/" }
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
minecraft "com.mojang:minecraft:${project.minecraft_version}"
|
||||||
|
if (project.minecraft_version.startsWith("26.")) {
|
||||||
|
implementation "net.fabricmc:fabric-loader:${project.loader_version}"
|
||||||
|
implementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_api_version}"
|
||||||
|
} else {
|
||||||
|
mappings loom.officialMojangMappings()
|
||||||
|
modImplementation "net.fabricmc:fabric-loader:${project.loader_version}"
|
||||||
|
modImplementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_api_version}"
|
||||||
|
}
|
||||||
|
if (project.minecraft_version.startsWith("26.")) {
|
||||||
|
compileOnly "com.terraformersmc:modmenu:${project.modmenu_version}"
|
||||||
|
} else {
|
||||||
|
modCompileOnly "com.terraformersmc:modmenu:${project.modmenu_version}"
|
||||||
|
}
|
||||||
|
testImplementation "org.junit.jupiter:junit-jupiter:5.10.2"
|
||||||
|
testRuntimeOnly "org.junit.platform:junit-platform-launcher"
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceSets.main.java.srcDir(
|
||||||
|
project.minecraft_version.startsWith("26.")
|
||||||
|
? "src/versions/modern/java"
|
||||||
|
: "src/versions/legacy/java"
|
||||||
|
)
|
||||||
|
|
||||||
|
tasks.withType(Test).configureEach {
|
||||||
|
useJUnitPlatform()
|
||||||
|
}
|
||||||
|
|
||||||
|
processResources {
|
||||||
|
inputs.property "version", project.version
|
||||||
|
inputs.property "minecraft_version", project.minecraft_version
|
||||||
|
|
||||||
|
filesMatching("fabric.mod.json") {
|
||||||
|
expand "version": project.version, "minecraft_version": project.minecraft_version
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.withType(JavaCompile).configureEach {
|
||||||
|
it.options.release = project.java_version.toInteger()
|
||||||
|
}
|
||||||
|
|
||||||
|
java {
|
||||||
|
withSourcesJar()
|
||||||
|
sourceCompatibility = JavaVersion.toVersion(project.java_version)
|
||||||
|
targetCompatibility = JavaVersion.toVersion(project.java_version)
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx2G
|
||||||
|
org.gradle.parallel=true
|
||||||
|
|
||||||
|
minecraft_version=26.1.2
|
||||||
|
loader_version=0.20.1
|
||||||
|
fabric_api_version=0.155.3+26.1.2
|
||||||
|
modmenu_version=18.0.2
|
||||||
|
loom_version=1.19.3
|
||||||
|
legacy_loom_version=1.14.10
|
||||||
|
java_version=25
|
||||||
|
|
||||||
|
mod_version=0.1.0
|
||||||
|
maven_group=dev.streamchat
|
||||||
|
archives_base_name=stream-chat
|
||||||
Vendored
BIN
Binary file not shown.
+7
@@ -0,0 +1,7 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.2-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# 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.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
#
|
||||||
|
# Gradle start up script for POSIX generated by Gradle.
|
||||||
|
#
|
||||||
|
# Important for running:
|
||||||
|
#
|
||||||
|
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||||
|
# noncompliant, but you have some other compliant shell such as ksh or
|
||||||
|
# bash, then to run this script, type that shell name before the whole
|
||||||
|
# command line, like:
|
||||||
|
#
|
||||||
|
# ksh Gradle
|
||||||
|
#
|
||||||
|
# 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».
|
||||||
|
#
|
||||||
|
# Important for patching:
|
||||||
|
#
|
||||||
|
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||||
|
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||||
|
#
|
||||||
|
# The "traditional" practice of packing multiple parameters into a
|
||||||
|
# space-separated string is a well documented source of bugs and security
|
||||||
|
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||||
|
# options in "$@", and eventually passing that to Java.
|
||||||
|
#
|
||||||
|
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||||
|
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||||
|
# see the in-line comments for details.
|
||||||
|
#
|
||||||
|
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||||
|
# Darwin, MinGW, and NonStop.
|
||||||
|
#
|
||||||
|
# (3) This script is generated from the Groovy template
|
||||||
|
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||||
|
# within the Gradle project.
|
||||||
|
#
|
||||||
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
|
#
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
app_path=$0
|
||||||
|
|
||||||
|
# Need this for daisy-chained symlinks.
|
||||||
|
while
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||||
|
[ -h "$app_path" ]
|
||||||
|
do
|
||||||
|
ls=$( ls -ld "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in #(
|
||||||
|
/*) app_path=$link ;; #(
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# This is normally unused
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
APP_BASE_NAME=${0##*/}
|
||||||
|
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||||
|
APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD=maximum
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "$( uname )" in #(
|
||||||
|
CYGWIN* ) cygwin=true ;; #(
|
||||||
|
Darwin* ) darwin=true ;; #(
|
||||||
|
MSYS* | MINGW* ) msys=true ;; #(
|
||||||
|
NONSTOP* ) nonstop=true ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||||
|
else
|
||||||
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD=java
|
||||||
|
if ! command -v java >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
|
case $MAX_FD in #(
|
||||||
|
max*)
|
||||||
|
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
|
warn "Could not query maximum file descriptor limit"
|
||||||
|
esac
|
||||||
|
case $MAX_FD in #(
|
||||||
|
'' | soft) :;; #(
|
||||||
|
*)
|
||||||
|
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
ulimit -n "$MAX_FD" ||
|
||||||
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, stacking in reverse order:
|
||||||
|
# * args from the command line
|
||||||
|
# * the main class name
|
||||||
|
# * -classpath
|
||||||
|
# * -D...appname settings
|
||||||
|
# * --module-path (only if needed)
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if "$cygwin" || "$msys" ; then
|
||||||
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
|
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||||
|
|
||||||
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
for arg do
|
||||||
|
if
|
||||||
|
case $arg in #(
|
||||||
|
-*) false ;; # don't mess with options #(
|
||||||
|
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||||
|
[ -e "$t" ] ;; #(
|
||||||
|
*) false ;;
|
||||||
|
esac
|
||||||
|
then
|
||||||
|
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||||
|
fi
|
||||||
|
# Roll the args list around exactly as many times as the number of
|
||||||
|
# args, so each arg winds up back in the position where it started, but
|
||||||
|
# possibly modified.
|
||||||
|
#
|
||||||
|
# NB: a `for` loop captures its iteration list before it begins, so
|
||||||
|
# changing the positional parameters here affects neither the number of
|
||||||
|
# iterations, nor the values presented in `arg`.
|
||||||
|
shift # remove old arg
|
||||||
|
set -- "$@" "$arg" # push replacement arg
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
# Collect all arguments for the java command:
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||||
|
# and any embedded shellness will be escaped.
|
||||||
|
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||||
|
# treated as '${Hostname}' itself on the command line.
|
||||||
|
|
||||||
|
set -- \
|
||||||
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
|
-classpath "$CLASSPATH" \
|
||||||
|
org.gradle.wrapper.GradleWrapperMain \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# Stop when "xargs" is not available.
|
||||||
|
if ! command -v xargs >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "xargs is not available"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use "xargs" to parse quoted args.
|
||||||
|
#
|
||||||
|
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||||
|
#
|
||||||
|
# In Bash we could simply go:
|
||||||
|
#
|
||||||
|
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||||
|
# set -- "${ARGS[@]}" "$@"
|
||||||
|
#
|
||||||
|
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||||
|
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||||
|
# character that might be a shell metacharacter, then use eval to reverse
|
||||||
|
# that process (while maintaining the separation between arguments), and wrap
|
||||||
|
# the whole thing up as a single "set" statement.
|
||||||
|
#
|
||||||
|
# This will of course break if any of these variables contains a newline or
|
||||||
|
# an unmatched quote.
|
||||||
|
#
|
||||||
|
|
||||||
|
eval "set -- $(
|
||||||
|
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||||
|
xargs -n1 |
|
||||||
|
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||||
|
tr '\n' ' '
|
||||||
|
)" '"$@"'
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
Vendored
+92
@@ -0,0 +1,92 @@
|
|||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem
|
||||||
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@rem you may not use this file except in compliance with the License.
|
||||||
|
@rem You may obtain a copy of the License at
|
||||||
|
@rem
|
||||||
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@rem
|
||||||
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
@rem See the License for the specific language governing permissions and
|
||||||
|
@rem limitations under the License.
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%"=="" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables with windows NT shell
|
||||||
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
|
@rem This is normally unused
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||||
|
|
||||||
|
:end
|
||||||
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||||
|
|
||||||
|
:fail
|
||||||
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
|
rem the _cmd.exe /c_ return code!
|
||||||
|
set EXIT_CODE=%ERRORLEVEL%
|
||||||
|
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||||
|
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||||
|
exit /b %EXIT_CODE%
|
||||||
|
|
||||||
|
:mainEnd
|
||||||
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|
||||||
|
:omega
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
maven { url = "https://maven.fabricmc.net/" }
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = "stream-chat"
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package dev.streamchat;
|
||||||
|
|
||||||
|
public enum DisplayTarget {
|
||||||
|
HIDDEN,
|
||||||
|
CHAT,
|
||||||
|
ACTION_BAR,
|
||||||
|
TOAST
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package dev.streamchat;
|
||||||
|
|
||||||
|
public enum Platform {
|
||||||
|
TWITCH,
|
||||||
|
YOUTUBE,
|
||||||
|
TIKTOK
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
package dev.streamchat;
|
||||||
|
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
import com.google.gson.GsonBuilder;
|
||||||
|
import com.google.gson.JsonParseException;
|
||||||
|
import net.fabricmc.loader.api.FabricLoader;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.EnumMap;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.logging.Level;
|
||||||
|
import java.util.logging.Logger;
|
||||||
|
|
||||||
|
public final class StreamChatConfig {
|
||||||
|
private static final Logger LOGGER = Logger.getLogger("StreamChat");
|
||||||
|
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
|
||||||
|
private static final Path PATH = FabricLoader.getInstance().getConfigDir().resolve("stream-chat.json");
|
||||||
|
|
||||||
|
public boolean twitchEnabled = true;
|
||||||
|
public boolean youtubeEnabled = true;
|
||||||
|
public boolean tiktokEnabled = true;
|
||||||
|
public boolean streamerBotEnabled = true;
|
||||||
|
public boolean tikFinityEnabled = true;
|
||||||
|
public String streamerBotUrl = "ws://127.0.0.1:8080/";
|
||||||
|
public String streamerBotPassword = "";
|
||||||
|
public String tikFinityUrl = "ws://127.0.0.1:21213/";
|
||||||
|
public Map<String, String> prefixes = new HashMap<>();
|
||||||
|
public Map<String, String> templates = new HashMap<>();
|
||||||
|
public Map<String, DisplayTarget> routes = new HashMap<>();
|
||||||
|
public Map<String, String> emoteReplacements = new HashMap<>();
|
||||||
|
|
||||||
|
public StreamChatConfig() {
|
||||||
|
prefixes.put("TWITCH", "[Twitch] | ");
|
||||||
|
prefixes.put("YOUTUBE", "[YouTube] | ");
|
||||||
|
prefixes.put("TIKTOK", "[TikTok] | ");
|
||||||
|
templates.put("TWITCH", "{prefix}{username} > {message}");
|
||||||
|
templates.put("YOUTUBE", "{prefix}{username} > {message}");
|
||||||
|
templates.put("TIKTOK", "{prefix}{username} > {message}");
|
||||||
|
setDefaultRoute("TWITCH", "ChatMessage", DisplayTarget.CHAT);
|
||||||
|
setDefaultRoute("YOUTUBE", "Message", DisplayTarget.CHAT);
|
||||||
|
setDefaultRoute("TIKTOK", "chat", DisplayTarget.CHAT);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static StreamChatConfig load() {
|
||||||
|
if (!Files.exists(PATH)) {
|
||||||
|
StreamChatConfig config = new StreamChatConfig();
|
||||||
|
config.save();
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
try (var reader = Files.newBufferedReader(PATH)) {
|
||||||
|
StreamChatConfig config = GSON.fromJson(reader, StreamChatConfig.class);
|
||||||
|
if (config == null) {
|
||||||
|
throw new JsonParseException("Configuration file was empty.");
|
||||||
|
}
|
||||||
|
config.applyMissingDefaults();
|
||||||
|
return config;
|
||||||
|
} catch (IOException | JsonParseException exception) {
|
||||||
|
LOGGER.log(Level.SEVERE, "Could not read " + PATH + "; using defaults.", exception);
|
||||||
|
return new StreamChatConfig();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void save() {
|
||||||
|
try {
|
||||||
|
Files.createDirectories(PATH.getParent());
|
||||||
|
try (var writer = Files.newBufferedWriter(PATH)) {
|
||||||
|
GSON.toJson(this, writer);
|
||||||
|
}
|
||||||
|
} catch (IOException exception) {
|
||||||
|
LOGGER.log(Level.SEVERE, "Could not save " + PATH, exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isEnabled(Platform platform) {
|
||||||
|
return switch (platform) {
|
||||||
|
case TWITCH -> twitchEnabled;
|
||||||
|
case YOUTUBE -> youtubeEnabled;
|
||||||
|
case TIKTOK -> tiktokEnabled;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setEnabled(Platform platform, boolean enabled) {
|
||||||
|
switch (platform) {
|
||||||
|
case TWITCH -> twitchEnabled = enabled;
|
||||||
|
case YOUTUBE -> youtubeEnabled = enabled;
|
||||||
|
case TIKTOK -> tiktokEnabled = enabled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public DisplayTarget getRoute(StreamEvent event) {
|
||||||
|
return routes.getOrDefault(routeKey(event.platform(), event.type()), DisplayTarget.HIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getPrefix(Platform platform) {
|
||||||
|
return prefixes.getOrDefault(platform.name(), "[" + platform.name() + "] | ");
|
||||||
|
}
|
||||||
|
|
||||||
|
public String format(StreamEvent event) {
|
||||||
|
String template = templates.getOrDefault(
|
||||||
|
event.platform().name(), "{prefix}{username} > {message}");
|
||||||
|
return template
|
||||||
|
.replace("{prefix}", getPrefix(event.platform()))
|
||||||
|
.replace("{platform}", event.platform().name())
|
||||||
|
.replace("{event}", event.type())
|
||||||
|
.replace("{username}", event.username())
|
||||||
|
.replace("{message}", replaceEmotes(event.message()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public String replaceEmotes(String message) {
|
||||||
|
String result = message;
|
||||||
|
for (Map.Entry<String, String> replacement : emoteReplacements.entrySet()) {
|
||||||
|
result = result.replace(replacement.getKey(), replacement.getValue());
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String routeKey(Platform platform, String eventType) {
|
||||||
|
return platform.name() + ":" + eventType;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applyMissingDefaults() {
|
||||||
|
StreamChatConfig defaults = new StreamChatConfig();
|
||||||
|
if (prefixes == null) prefixes = defaults.prefixes;
|
||||||
|
if (templates == null) templates = defaults.templates;
|
||||||
|
if (routes == null) routes = defaults.routes;
|
||||||
|
if (emoteReplacements == null) emoteReplacements = new HashMap<>();
|
||||||
|
if (streamerBotUrl == null || streamerBotUrl.isBlank()) streamerBotUrl = defaults.streamerBotUrl;
|
||||||
|
if (tikFinityUrl == null || tikFinityUrl.isBlank()) tikFinityUrl = defaults.tikFinityUrl;
|
||||||
|
if (streamerBotPassword == null) streamerBotPassword = "";
|
||||||
|
defaults.routes.forEach(routes::putIfAbsent);
|
||||||
|
defaults.prefixes.forEach(prefixes::putIfAbsent);
|
||||||
|
defaults.templates.forEach(templates::putIfAbsent);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void setDefaultRoute(String platform, String event, DisplayTarget target) {
|
||||||
|
routes.put(platform + ":" + event, target);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package dev.streamchat;
|
||||||
|
|
||||||
|
public record StreamEvent(
|
||||||
|
Platform platform,
|
||||||
|
String type,
|
||||||
|
String username,
|
||||||
|
String message,
|
||||||
|
String rawJson
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package dev.streamchat;
|
||||||
|
|
||||||
|
import com.google.gson.JsonElement;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import com.google.gson.JsonParser;
|
||||||
|
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
public final class StreamEventParser {
|
||||||
|
private StreamEventParser() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static StreamEvent parseTikFinity(String text) {
|
||||||
|
JsonObject root = JsonParser.parseString(text).getAsJsonObject();
|
||||||
|
String type = string(root, "event");
|
||||||
|
JsonObject data = object(root, "data");
|
||||||
|
String username = firstString(data, "uniqueId", "nickname", "username", "userName", "sender");
|
||||||
|
String message = firstString(data, "comment", "message", "giftName", "shareType", "label");
|
||||||
|
if (message.isBlank()) {
|
||||||
|
message = summarize(data);
|
||||||
|
}
|
||||||
|
return new StreamEvent(Platform.TIKTOK, type, username, message, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static StreamEvent parseStreamerBot(String text) {
|
||||||
|
JsonObject root = JsonParser.parseString(text).getAsJsonObject();
|
||||||
|
JsonObject event = object(root, "event");
|
||||||
|
JsonObject data = object(root, "data");
|
||||||
|
String source = string(event, "source").toLowerCase(Locale.ROOT);
|
||||||
|
Platform platform = source.contains("youtube") ? Platform.YOUTUBE : Platform.TWITCH;
|
||||||
|
String type = string(event, "type");
|
||||||
|
String username = firstString(data, "username", "userName", "displayName", "user");
|
||||||
|
String message = firstString(data, "message", "text", "comment", "giftName");
|
||||||
|
if (message.isBlank()) {
|
||||||
|
message = summarize(data);
|
||||||
|
}
|
||||||
|
return new StreamEvent(platform, type, username, message, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonObject object(JsonObject object, String key) {
|
||||||
|
JsonElement value = object.get(key);
|
||||||
|
return value != null && value.isJsonObject() ? value.getAsJsonObject() : new JsonObject();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String firstString(JsonObject object, String... keys) {
|
||||||
|
for (String key : keys) {
|
||||||
|
String value = string(object, key);
|
||||||
|
if (!value.isBlank()) return value;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String string(JsonObject object, String key) {
|
||||||
|
JsonElement value = object.get(key);
|
||||||
|
return value == null || value.isJsonNull() || value.isJsonObject() || value.isJsonArray()
|
||||||
|
? "" : value.getAsString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String summarize(JsonObject data) {
|
||||||
|
return data.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"key.streamchat.toggle_connections": "Toggle Stream Chat Connections",
|
||||||
|
"category.streamchat": "Stream Chat"
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"id": "streamchat",
|
||||||
|
"version": "${version}",
|
||||||
|
"name": "Stream Chat",
|
||||||
|
"description": "Displays livestream chat and events in the Minecraft client.",
|
||||||
|
"authors": ["Stream Chat contributors"],
|
||||||
|
"environment": "client",
|
||||||
|
"entrypoints": {
|
||||||
|
"client": ["dev.streamchat.StreamChatClient"],
|
||||||
|
"modmenu": ["dev.streamchat.ModMenuIntegration"]
|
||||||
|
},
|
||||||
|
"depends": {
|
||||||
|
"fabricloader": ">=0.15.0",
|
||||||
|
"minecraft": "${minecraft_version}",
|
||||||
|
"java": ">=17",
|
||||||
|
"fabric-api": "*"
|
||||||
|
},
|
||||||
|
"suggests": {
|
||||||
|
"modmenu": "*"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package dev.streamchat;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
|
||||||
|
class StreamEventParserTest {
|
||||||
|
@Test
|
||||||
|
void parsesTikFinityChatEnvelope() {
|
||||||
|
StreamEvent event = StreamEventParser.parseTikFinity("""
|
||||||
|
{
|
||||||
|
"event": "chat",
|
||||||
|
"data": {
|
||||||
|
"uniqueId": "viewer_one",
|
||||||
|
"nickname": "Viewer One",
|
||||||
|
"comment": "Hello!"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
|
||||||
|
assertEquals(Platform.TIKTOK, event.platform());
|
||||||
|
assertEquals("chat", event.type());
|
||||||
|
assertEquals("viewer_one", event.username());
|
||||||
|
assertEquals("Hello!", event.message());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void parsesStreamerBotYouTubeEventEnvelope() {
|
||||||
|
StreamEvent event = StreamEventParser.parseStreamerBot("""
|
||||||
|
{
|
||||||
|
"event": { "source": "YouTube", "type": "Message" },
|
||||||
|
"data": { "userName": "viewer_two", "message": "Hi from YouTube" }
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
|
||||||
|
assertEquals(Platform.YOUTUBE, event.platform());
|
||||||
|
assertEquals("Message", event.type());
|
||||||
|
assertEquals("viewer_two", event.username());
|
||||||
|
assertEquals("Hi from YouTube", event.message());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,397 @@
|
|||||||
|
package dev.streamchat;
|
||||||
|
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import com.google.gson.JsonParser;
|
||||||
|
import net.minecraft.client.Minecraft;
|
||||||
|
import net.minecraft.network.chat.Component;
|
||||||
|
import net.minecraft.client.gui.components.toasts.SystemToast;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.WebSocket;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.EnumMap;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.CompletionStage;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.ScheduledExecutorService;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.logging.Level;
|
||||||
|
import java.util.logging.Logger;
|
||||||
|
|
||||||
|
public final class ConnectionManager {
|
||||||
|
private static final Logger LOGGER = Logger.getLogger("StreamChat");
|
||||||
|
private static final String[] TWITCH_EVENTS = {
|
||||||
|
"ChatMessage", "Follow", "Sub", "ReSub", "GiftSub", "Cheer", "Raid"
|
||||||
|
};
|
||||||
|
private static final String[] YOUTUBE_EVENTS = {
|
||||||
|
"Message", "SuperChat", "SuperSticker", "MemberMilestone",
|
||||||
|
"NewSponsor", "GiftMembershipReceived"
|
||||||
|
};
|
||||||
|
|
||||||
|
private final Minecraft client;
|
||||||
|
private final StreamChatConfig config;
|
||||||
|
private final HttpClient httpClient = HttpClient.newBuilder()
|
||||||
|
.connectTimeout(Duration.ofSeconds(10))
|
||||||
|
.build();
|
||||||
|
private final ScheduledExecutorService retryExecutor = Executors.newSingleThreadScheduledExecutor(task -> {
|
||||||
|
Thread thread = new Thread(task, "Stream Chat reconnect");
|
||||||
|
thread.setDaemon(true);
|
||||||
|
return thread;
|
||||||
|
});
|
||||||
|
private final Map<Platform, WebSocket> sockets = new EnumMap<>(Platform.class);
|
||||||
|
private final Map<Platform, String> statuses = new EnumMap<>(Platform.class);
|
||||||
|
private final Map<Platform, Instant> lastEvents = new EnumMap<>(Platform.class);
|
||||||
|
private final Map<Platform, Integer> retryCounts = new EnumMap<>(Platform.class);
|
||||||
|
private final java.util.Set<Platform> scheduledRetries = java.util.EnumSet.noneOf(Platform.class);
|
||||||
|
private boolean manuallyPaused;
|
||||||
|
private volatile long connectionGeneration;
|
||||||
|
private String authenticationRequestId;
|
||||||
|
|
||||||
|
public ConnectionManager(Minecraft client, StreamChatConfig config) {
|
||||||
|
this.client = client;
|
||||||
|
this.config = config;
|
||||||
|
for (Platform platform : Platform.values()) {
|
||||||
|
statuses.put(platform, "Disconnected");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized void start() {
|
||||||
|
manuallyPaused = false;
|
||||||
|
connectEnabledPlatforms();
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized void toggleConnections() {
|
||||||
|
manuallyPaused = !manuallyPaused;
|
||||||
|
if (manuallyPaused) {
|
||||||
|
closeAll();
|
||||||
|
} else {
|
||||||
|
connectEnabledPlatforms();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized void configurationChanged() {
|
||||||
|
if (!manuallyPaused) {
|
||||||
|
closeAll();
|
||||||
|
connectEnabledPlatforms();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized String status(Platform platform) {
|
||||||
|
return statuses.getOrDefault(platform, "Disconnected");
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized String lastEvent(Platform platform) {
|
||||||
|
Instant timestamp = lastEvents.get(platform);
|
||||||
|
return timestamp == null ? "Never" : timestamp.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void connectEnabledPlatforms() {
|
||||||
|
if (config.streamerBotEnabled && (config.twitchEnabled || config.youtubeEnabled)) {
|
||||||
|
connect(Platform.TWITCH, config.streamerBotUrl);
|
||||||
|
} else {
|
||||||
|
statuses.put(Platform.TWITCH, "Disabled");
|
||||||
|
statuses.put(Platform.YOUTUBE, "Disabled");
|
||||||
|
}
|
||||||
|
if (config.tikFinityEnabled && config.tiktokEnabled) {
|
||||||
|
connect(Platform.TIKTOK, config.tikFinityUrl);
|
||||||
|
} else {
|
||||||
|
statuses.put(Platform.TIKTOK, "Disabled");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private synchronized void connect(Platform platform, String address) {
|
||||||
|
long generation = connectionGeneration;
|
||||||
|
statuses.put(platform, "Connecting");
|
||||||
|
try {
|
||||||
|
httpClient.newWebSocketBuilder()
|
||||||
|
.connectTimeout(Duration.ofSeconds(10))
|
||||||
|
.buildAsync(URI.create(address), new Listener(platform, generation))
|
||||||
|
.whenComplete((socket, error) -> {
|
||||||
|
synchronized (ConnectionManager.this) {
|
||||||
|
if (generation != connectionGeneration) {
|
||||||
|
if (socket != null) socket.sendClose(WebSocket.NORMAL_CLOSURE, "Configuration changed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (error != null) {
|
||||||
|
statuses.put(platform, "Error: " + error.getMessage());
|
||||||
|
LOGGER.log(Level.WARNING, platform + " connection failed", error);
|
||||||
|
scheduleReconnect(platform, address, generation);
|
||||||
|
} else {
|
||||||
|
sockets.put(platform, socket);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (IllegalArgumentException exception) {
|
||||||
|
statuses.put(platform, "Error: " + exception.getMessage());
|
||||||
|
LOGGER.log(Level.WARNING, "Invalid WebSocket address for " + platform, exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private synchronized void closeAll() {
|
||||||
|
connectionGeneration++;
|
||||||
|
for (Map.Entry<Platform, WebSocket> entry : sockets.entrySet()) {
|
||||||
|
entry.getValue().sendClose(WebSocket.NORMAL_CLOSURE, "Disconnected by user");
|
||||||
|
statuses.put(entry.getKey(), "Disconnected");
|
||||||
|
if (entry.getKey() == Platform.TWITCH && config.youtubeEnabled) {
|
||||||
|
statuses.put(Platform.YOUTUBE, "Disconnected");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sockets.clear();
|
||||||
|
scheduledRetries.clear();
|
||||||
|
retryCounts.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendStreamerBotSubscription(WebSocket socket) {
|
||||||
|
JsonObject events = new JsonObject();
|
||||||
|
if (config.twitchEnabled) {
|
||||||
|
events.add("Twitch", strings(TWITCH_EVENTS));
|
||||||
|
}
|
||||||
|
if (config.youtubeEnabled) {
|
||||||
|
events.add("YouTube", strings(YOUTUBE_EVENTS));
|
||||||
|
}
|
||||||
|
JsonObject request = new JsonObject();
|
||||||
|
request.addProperty("request", "Subscribe");
|
||||||
|
request.addProperty("id", UUID.randomUUID().toString());
|
||||||
|
request.add("events", events);
|
||||||
|
socket.sendText(request.toString(), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private com.google.gson.JsonArray strings(String[] values) {
|
||||||
|
com.google.gson.JsonArray array = new com.google.gson.JsonArray();
|
||||||
|
for (String value : values) array.add(value);
|
||||||
|
return array;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void receive(Platform socketPlatform, String raw) {
|
||||||
|
try {
|
||||||
|
JsonObject root = JsonParser.parseString(raw).getAsJsonObject();
|
||||||
|
if (socketPlatform == Platform.TWITCH && handleStreamerBotControl(root)) return;
|
||||||
|
StreamEvent event = socketPlatform == Platform.TIKTOK
|
||||||
|
? StreamEventParser.parseTikFinity(raw)
|
||||||
|
: StreamEventParser.parseStreamerBot(raw);
|
||||||
|
synchronized (this) {
|
||||||
|
lastEvents.put(event.platform(), Instant.now());
|
||||||
|
statuses.put(event.platform(), "Connected");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isKnownEvent(event)) {
|
||||||
|
LOGGER.warning("Unrecognized " + event.platform() + " event: " + raw);
|
||||||
|
}
|
||||||
|
if (!config.isEnabled(event.platform())) return;
|
||||||
|
DisplayTarget target = config.getRoute(event);
|
||||||
|
if (target != DisplayTarget.HIDDEN) {
|
||||||
|
client.execute(() -> display(event, target));
|
||||||
|
}
|
||||||
|
} catch (RuntimeException exception) {
|
||||||
|
LOGGER.log(Level.WARNING, "Could not parse event from " + socketPlatform + ": " + raw, exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private synchronized void scheduleReconnect(Platform platform, String address, long generation) {
|
||||||
|
if (generation != connectionGeneration || manuallyPaused || !isConnectionEnabled(platform)
|
||||||
|
|| !scheduledRetries.add(platform)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int attempts = retryCounts.merge(platform, 1, Integer::sum);
|
||||||
|
long delaySeconds = Math.min(30, 1L << Math.min(attempts, 5));
|
||||||
|
statuses.put(platform, "Reconnecting in " + delaySeconds + "s");
|
||||||
|
retryExecutor.schedule(() -> {
|
||||||
|
synchronized (ConnectionManager.this) {
|
||||||
|
scheduledRetries.remove(platform);
|
||||||
|
if (generation == connectionGeneration && !manuallyPaused && isConnectionEnabled(platform)) {
|
||||||
|
connect(platform, address);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, delaySeconds, TimeUnit.SECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isConnectionEnabled(Platform platform) {
|
||||||
|
return platform == Platform.TWITCH
|
||||||
|
? config.streamerBotEnabled && (config.twitchEnabled || config.youtubeEnabled)
|
||||||
|
: config.tikFinityEnabled && config.tiktokEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean handleStreamerBotControl(JsonObject root) {
|
||||||
|
String request = root.has("request") ? root.get("request").getAsString() : "";
|
||||||
|
if ("Hello".equalsIgnoreCase(request)) {
|
||||||
|
JsonObject authentication = root.has("authentication") && root.get("authentication").isJsonObject()
|
||||||
|
? root.getAsJsonObject("authentication") : new JsonObject();
|
||||||
|
String salt = value(authentication, "salt");
|
||||||
|
String challenge = value(authentication, "challenge");
|
||||||
|
if (!salt.isBlank() && !challenge.isBlank() && config.streamerBotPassword.isBlank()) {
|
||||||
|
statuses.put(Platform.TWITCH, "Error: Streamer.bot password required");
|
||||||
|
if (config.youtubeEnabled) statuses.put(Platform.YOUTUBE, "Error: Streamer.bot password required");
|
||||||
|
} else if (!config.streamerBotPassword.isBlank() && !salt.isBlank() && !challenge.isBlank()) {
|
||||||
|
JsonObject authRequest = new JsonObject();
|
||||||
|
authRequest.addProperty("request", "Authenticate");
|
||||||
|
authenticationRequestId = UUID.randomUUID().toString();
|
||||||
|
authRequest.addProperty("id", authenticationRequestId);
|
||||||
|
authRequest.addProperty("authentication", sha256(challenge + salt + config.streamerBotPassword));
|
||||||
|
WebSocket socket = sockets.get(Platform.TWITCH);
|
||||||
|
if (socket != null) socket.sendText(authRequest.toString(), true);
|
||||||
|
} else {
|
||||||
|
WebSocket socket = sockets.get(Platform.TWITCH);
|
||||||
|
if (socket != null) sendStreamerBotSubscription(socket);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (root.has("id") && root.has("status")) {
|
||||||
|
String status = value(root, "status");
|
||||||
|
if ("ok".equalsIgnoreCase(status)) {
|
||||||
|
synchronized (this) {
|
||||||
|
statuses.put(Platform.TWITCH, "Connected");
|
||||||
|
statuses.put(Platform.YOUTUBE, config.youtubeEnabled ? "Connected" : "Disabled");
|
||||||
|
}
|
||||||
|
if (authenticationRequestId != null
|
||||||
|
&& authenticationRequestId.equals(value(root, "id"))) {
|
||||||
|
authenticationRequestId = null;
|
||||||
|
WebSocket socket = sockets.get(Platform.TWITCH);
|
||||||
|
if (socket != null) sendStreamerBotSubscription(socket);
|
||||||
|
}
|
||||||
|
} else if (!status.isBlank()) {
|
||||||
|
String reason = value(root, "error");
|
||||||
|
synchronized (this) {
|
||||||
|
statuses.put(Platform.TWITCH, "Error: " + (reason.isBlank() ? status : reason));
|
||||||
|
if (config.youtubeEnabled) {
|
||||||
|
statuses.put(Platform.YOUTUBE, "Error: " + (reason.isBlank() ? status : reason));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LOGGER.warning("Streamer.bot request returned status: " + root);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (root.has("event")) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String sha256(String value) {
|
||||||
|
try {
|
||||||
|
byte[] digest = MessageDigest.getInstance("SHA-256")
|
||||||
|
.digest(value.getBytes(StandardCharsets.UTF_8));
|
||||||
|
StringBuilder result = new StringBuilder(digest.length * 2);
|
||||||
|
for (byte item : digest) result.append(String.format(Locale.ROOT, "%02x", item));
|
||||||
|
return result.toString();
|
||||||
|
} catch (NoSuchAlgorithmException exception) {
|
||||||
|
throw new IllegalStateException("SHA-256 is unavailable", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String value(JsonObject object, String key) {
|
||||||
|
return object.has(key) && !object.get(key).isJsonNull() ? object.get(key).getAsString() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isKnownEvent(StreamEvent event) {
|
||||||
|
if (event.platform() == Platform.TIKTOK) {
|
||||||
|
return switch (event.type()) {
|
||||||
|
case "chat", "gift", "share", "follow", "like", "roomUser", "subscribe" -> true;
|
||||||
|
default -> false;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return event.platform() == Platform.TWITCH
|
||||||
|
? contains(TWITCH_EVENTS, event.type())
|
||||||
|
: contains(YOUTUBE_EVENTS, event.type());
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean contains(String[] values, String target) {
|
||||||
|
for (String value : values) if (value.equalsIgnoreCase(target)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void display(StreamEvent event, DisplayTarget target) {
|
||||||
|
String text = config.format(event);
|
||||||
|
Component component = Component.literal(text);
|
||||||
|
switch (target) {
|
||||||
|
case CHAT -> {
|
||||||
|
if (client.gui != null) client.gui.getChat().addMessage(component);
|
||||||
|
}
|
||||||
|
case ACTION_BAR -> {
|
||||||
|
if (client.player != null) client.player.displayClientMessage(component, true);
|
||||||
|
}
|
||||||
|
case TOAST -> SystemToast.addOrUpdate(
|
||||||
|
client.getToasts(),
|
||||||
|
SystemToast.SystemToastIds.TUTORIAL_HINT,
|
||||||
|
Component.literal(event.platform() + " " + event.type()),
|
||||||
|
Component.literal(text));
|
||||||
|
case HIDDEN -> {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class Listener implements WebSocket.Listener {
|
||||||
|
private final Platform platform;
|
||||||
|
private final long generation;
|
||||||
|
private final StringBuilder fragments = new StringBuilder();
|
||||||
|
|
||||||
|
private Listener(Platform platform, long generation) {
|
||||||
|
this.platform = platform;
|
||||||
|
this.generation = generation;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onOpen(WebSocket webSocket) {
|
||||||
|
synchronized (ConnectionManager.this) {
|
||||||
|
if (generation != connectionGeneration) {
|
||||||
|
webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "Configuration changed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sockets.put(platform, webSocket);
|
||||||
|
boolean platformEnabled = platform == Platform.TIKTOK
|
||||||
|
? config.tiktokEnabled : config.twitchEnabled;
|
||||||
|
statuses.put(platform, platformEnabled ? "Connected" : "Disabled");
|
||||||
|
retryCounts.remove(platform);
|
||||||
|
scheduledRetries.remove(platform);
|
||||||
|
if (platform == Platform.TWITCH) {
|
||||||
|
statuses.put(Platform.YOUTUBE, config.youtubeEnabled ? "Connected" : "Disabled");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
webSocket.request(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CompletionStage<?> onText(WebSocket webSocket, CharSequence data, boolean last) {
|
||||||
|
if (generation != connectionGeneration) return null;
|
||||||
|
fragments.append(data);
|
||||||
|
if (last) {
|
||||||
|
receive(platform, fragments.toString());
|
||||||
|
fragments.setLength(0);
|
||||||
|
}
|
||||||
|
webSocket.request(1);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CompletionStage<?> onClose(WebSocket webSocket, int statusCode, String reason) {
|
||||||
|
synchronized (ConnectionManager.this) {
|
||||||
|
if (generation == connectionGeneration) {
|
||||||
|
sockets.remove(platform);
|
||||||
|
statuses.put(platform, "Disconnected");
|
||||||
|
if (platform == Platform.TWITCH && config.youtubeEnabled) {
|
||||||
|
statuses.put(Platform.YOUTUBE, "Disconnected");
|
||||||
|
}
|
||||||
|
scheduleReconnect(platform, platform == Platform.TWITCH
|
||||||
|
? config.streamerBotUrl : config.tikFinityUrl, generation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(WebSocket webSocket, Throwable error) {
|
||||||
|
synchronized (ConnectionManager.this) {
|
||||||
|
if (generation == connectionGeneration) {
|
||||||
|
statuses.put(platform, "Error: " + error.getMessage());
|
||||||
|
scheduleReconnect(platform, platform == Platform.TWITCH
|
||||||
|
? config.streamerBotUrl : config.tikFinityUrl, generation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LOGGER.log(Level.WARNING, platform + " WebSocket error", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package dev.streamchat;
|
||||||
|
|
||||||
|
import com.terraformersmc.modmenu.api.ConfigScreenFactory;
|
||||||
|
import com.terraformersmc.modmenu.api.ModMenuApi;
|
||||||
|
|
||||||
|
public final class ModMenuIntegration implements ModMenuApi {
|
||||||
|
@Override
|
||||||
|
public ConfigScreenFactory<?> getModConfigScreenFactory() {
|
||||||
|
return parent -> new StreamChatScreen(parent, StreamChatClient.CONFIG, StreamChatClient.CONNECTIONS);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package dev.streamchat;
|
||||||
|
|
||||||
|
import net.fabricmc.api.ClientModInitializer;
|
||||||
|
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
|
||||||
|
import net.fabricmc.fabric.api.client.keybinding.v1.KeyBindingHelper;
|
||||||
|
import net.minecraft.client.KeyMapping;
|
||||||
|
import net.minecraft.client.Minecraft;
|
||||||
|
import org.lwjgl.glfw.GLFW;
|
||||||
|
|
||||||
|
public final class StreamChatClient implements ClientModInitializer {
|
||||||
|
private static final KeyMapping TOGGLE_CONNECTIONS = KeyBindingHelper.registerKeyBinding(
|
||||||
|
new KeyMapping("key.streamchat.toggle_connections", GLFW.GLFW_KEY_UNKNOWN, "category.streamchat"));
|
||||||
|
|
||||||
|
public static StreamChatConfig CONFIG;
|
||||||
|
public static ConnectionManager CONNECTIONS;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onInitializeClient() {
|
||||||
|
CONFIG = StreamChatConfig.load();
|
||||||
|
CONNECTIONS = new ConnectionManager(Minecraft.getInstance(), CONFIG);
|
||||||
|
CONNECTIONS.start();
|
||||||
|
ClientTickEvents.END_CLIENT_TICK.register(client -> {
|
||||||
|
while (TOGGLE_CONNECTIONS.consumeClick()) {
|
||||||
|
CONNECTIONS.toggleConnections();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
package dev.streamchat;
|
||||||
|
|
||||||
|
import net.minecraft.client.gui.GuiGraphics;
|
||||||
|
import net.minecraft.client.gui.components.Button;
|
||||||
|
import net.minecraft.client.gui.components.EditBox;
|
||||||
|
import net.minecraft.client.gui.screens.Screen;
|
||||||
|
import net.minecraft.network.chat.Component;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public final class StreamChatScreen extends Screen {
|
||||||
|
private static final List<String> TWITCH_EVENTS =
|
||||||
|
List.of("ChatMessage", "Follow", "Sub", "ReSub", "GiftSub", "Cheer", "Raid");
|
||||||
|
private static final List<String> YOUTUBE_EVENTS =
|
||||||
|
List.of("Message", "SuperChat", "SuperSticker", "MemberMilestone", "NewSponsor", "GiftMembershipReceived");
|
||||||
|
private static final List<String> TIKTOK_EVENTS =
|
||||||
|
List.of("chat", "gift", "share", "follow", "like", "roomUser", "subscribe");
|
||||||
|
|
||||||
|
private final Screen parent;
|
||||||
|
private final StreamChatConfig config;
|
||||||
|
private final ConnectionManager connections;
|
||||||
|
private Platform selectedPlatform = Platform.TWITCH;
|
||||||
|
private EditBox streamerBotUrl;
|
||||||
|
private EditBox streamerBotPassword;
|
||||||
|
private EditBox tikFinityUrl;
|
||||||
|
private EditBox prefix;
|
||||||
|
private EditBox template;
|
||||||
|
|
||||||
|
public StreamChatScreen(Screen parent, StreamChatConfig config, ConnectionManager connections) {
|
||||||
|
super(Component.literal("Stream Chat Settings"));
|
||||||
|
this.parent = parent;
|
||||||
|
this.config = config;
|
||||||
|
this.connections = connections;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void init() {
|
||||||
|
int left = width / 2 - 190;
|
||||||
|
int rowWidth = 380;
|
||||||
|
int buttonWidth = 120;
|
||||||
|
|
||||||
|
addRenderableWidget(Button.builder(toggleLabel(Platform.TWITCH), button -> {
|
||||||
|
config.twitchEnabled = !config.twitchEnabled;
|
||||||
|
button.setMessage(toggleLabel(Platform.TWITCH));
|
||||||
|
}).bounds(left, 28, buttonWidth, 20).build());
|
||||||
|
addRenderableWidget(Button.builder(toggleLabel(Platform.YOUTUBE), button -> {
|
||||||
|
config.youtubeEnabled = !config.youtubeEnabled;
|
||||||
|
button.setMessage(toggleLabel(Platform.YOUTUBE));
|
||||||
|
}).bounds(left + 130, 28, buttonWidth, 20).build());
|
||||||
|
addRenderableWidget(Button.builder(toggleLabel(Platform.TIKTOK), button -> {
|
||||||
|
config.tiktokEnabled = !config.tiktokEnabled;
|
||||||
|
button.setMessage(toggleLabel(Platform.TIKTOK));
|
||||||
|
}).bounds(left + 260, 28, buttonWidth, 20).build());
|
||||||
|
|
||||||
|
streamerBotUrl = new EditBox(font, left, 75, rowWidth, 20, Component.literal("Streamer.bot WebSocket URL"));
|
||||||
|
streamerBotUrl.setValue(config.streamerBotUrl);
|
||||||
|
streamerBotUrl.setMaxLength(256);
|
||||||
|
addRenderableWidget(streamerBotUrl);
|
||||||
|
|
||||||
|
streamerBotPassword = new EditBox(font, left, 105, rowWidth, 20, Component.literal("Streamer.bot password"));
|
||||||
|
streamerBotPassword.setValue(config.streamerBotPassword);
|
||||||
|
streamerBotPassword.setMaxLength(256);
|
||||||
|
streamerBotPassword.setSuggestion("Streamer.bot password (stored in config file)");
|
||||||
|
addRenderableWidget(streamerBotPassword);
|
||||||
|
|
||||||
|
tikFinityUrl = new EditBox(font, left, 135, rowWidth, 20, Component.literal("TikFinity WebSocket URL"));
|
||||||
|
tikFinityUrl.setValue(config.tikFinityUrl);
|
||||||
|
tikFinityUrl.setMaxLength(256);
|
||||||
|
addRenderableWidget(tikFinityUrl);
|
||||||
|
|
||||||
|
addRenderableWidget(Button.builder(Component.literal("Streamer.bot: " + (config.streamerBotEnabled ? "On" : "Off")),
|
||||||
|
button -> {
|
||||||
|
config.streamerBotEnabled = !config.streamerBotEnabled;
|
||||||
|
button.setMessage(Component.literal("Streamer.bot: " + (config.streamerBotEnabled ? "On" : "Off")));
|
||||||
|
}).bounds(left, 165, 180, 20).build());
|
||||||
|
addRenderableWidget(Button.builder(Component.literal("TikFinity: " + (config.tikFinityEnabled ? "On" : "Off")),
|
||||||
|
button -> {
|
||||||
|
config.tikFinityEnabled = !config.tikFinityEnabled;
|
||||||
|
button.setMessage(Component.literal("TikFinity: " + (config.tikFinityEnabled ? "On" : "Off")));
|
||||||
|
}).bounds(left + 200, 165, 180, 20).build());
|
||||||
|
|
||||||
|
addRenderableWidget(Button.builder(Component.literal("Twitch"), button -> select(Platform.TWITCH))
|
||||||
|
.bounds(left, 200, 120, 20).build());
|
||||||
|
addRenderableWidget(Button.builder(Component.literal("YouTube"), button -> select(Platform.YOUTUBE))
|
||||||
|
.bounds(left + 130, 200, 120, 20).build());
|
||||||
|
addRenderableWidget(Button.builder(Component.literal("TikTok"), button -> select(Platform.TIKTOK))
|
||||||
|
.bounds(left + 260, 200, 120, 20).build());
|
||||||
|
|
||||||
|
prefix = new EditBox(font, left, 230, rowWidth, 20, Component.literal("Platform prefix"));
|
||||||
|
prefix.setValue(config.getPrefix(selectedPlatform));
|
||||||
|
prefix.setMaxLength(128);
|
||||||
|
addRenderableWidget(prefix);
|
||||||
|
template = new EditBox(font, left, 270, rowWidth, 20, Component.literal("Message format"));
|
||||||
|
template.setValue(config.templates.getOrDefault(selectedPlatform.name(),
|
||||||
|
"{prefix}{username} > {message}"));
|
||||||
|
template.setMaxLength(256);
|
||||||
|
addRenderableWidget(template);
|
||||||
|
addRouteButtons(left);
|
||||||
|
|
||||||
|
addRenderableWidget(Button.builder(Component.literal("Toggle connections"), button -> connections.toggleConnections())
|
||||||
|
.bounds(left, height - 32, 180, 20).build());
|
||||||
|
addRenderableWidget(Button.builder(Component.literal("Done"), button -> onClose())
|
||||||
|
.bounds(left + 200, height - 32, 180, 20).build());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void render(GuiGraphics graphics, int mouseX, int mouseY, float delta) {
|
||||||
|
renderBackground(graphics);
|
||||||
|
graphics.drawCenteredString(font, title, width / 2, 8, 0xFFFFFF);
|
||||||
|
int left = width / 2 - 190;
|
||||||
|
graphics.drawString(font, "Streamer.bot / TikFinity WebSocket settings", left, 60, 0xA0A0A0);
|
||||||
|
graphics.drawString(font, "Connection status: " + connections.status(selectedPlatform), left, 188, 0xFFFFFF);
|
||||||
|
graphics.drawString(font, "Last event: " + connections.lastEvent(selectedPlatform), left + 180, 188, 0xA0A0A0);
|
||||||
|
graphics.drawString(font, "Prefix", left, 220, 0xA0A0A0);
|
||||||
|
graphics.drawString(font, "Format: {prefix}, {platform}, {event}, {username}, {message}", left, 256, 0xA0A0A0);
|
||||||
|
graphics.drawString(font, "Event destination (click to cycle)", left, 296, 0xA0A0A0);
|
||||||
|
super.render(graphics, mouseX, mouseY, delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onClose() {
|
||||||
|
config.streamerBotUrl = streamerBotUrl.getValue().trim();
|
||||||
|
config.streamerBotPassword = streamerBotPassword.getValue();
|
||||||
|
config.tikFinityUrl = tikFinityUrl.getValue().trim();
|
||||||
|
config.prefixes.put(selectedPlatform.name(), prefix.getValue());
|
||||||
|
config.templates.put(selectedPlatform.name(), template.getValue());
|
||||||
|
config.save();
|
||||||
|
connections.configurationChanged();
|
||||||
|
minecraft.setScreen(parent);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void select(Platform platform) {
|
||||||
|
config.streamerBotUrl = streamerBotUrl.getValue().trim();
|
||||||
|
config.streamerBotPassword = streamerBotPassword.getValue();
|
||||||
|
config.tikFinityUrl = tikFinityUrl.getValue().trim();
|
||||||
|
config.prefixes.put(selectedPlatform.name(), prefix.getValue());
|
||||||
|
config.templates.put(selectedPlatform.name(), template.getValue());
|
||||||
|
selectedPlatform = platform;
|
||||||
|
prefix.setValue(config.getPrefix(platform));
|
||||||
|
template.setValue(config.templates.getOrDefault(platform.name(),
|
||||||
|
"{prefix}{username} > {message}"));
|
||||||
|
rebuildRoutes();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void rebuildRoutes() {
|
||||||
|
clearWidgets();
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addRouteButtons(int left) {
|
||||||
|
List<String> events = switch (selectedPlatform) {
|
||||||
|
case TWITCH -> TWITCH_EVENTS;
|
||||||
|
case YOUTUBE -> YOUTUBE_EVENTS;
|
||||||
|
case TIKTOK -> TIKTOK_EVENTS;
|
||||||
|
};
|
||||||
|
for (int i = 0; i < events.size(); i++) {
|
||||||
|
String eventType = events.get(i);
|
||||||
|
int x = left + (i % 2) * 195;
|
||||||
|
int y = 320 + (i / 2) * 24;
|
||||||
|
addRenderableWidget(Button.builder(routeLabel(eventType), button -> {
|
||||||
|
String key = StreamChatConfig.routeKey(selectedPlatform, eventType);
|
||||||
|
DisplayTarget[] targets = DisplayTarget.values();
|
||||||
|
DisplayTarget current = config.routes.getOrDefault(key, DisplayTarget.HIDDEN);
|
||||||
|
config.routes.put(key, targets[(current.ordinal() + 1) % targets.length]);
|
||||||
|
button.setMessage(routeLabel(eventType));
|
||||||
|
}).bounds(x, y, 185, 20).build());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Component routeLabel(String eventType) {
|
||||||
|
DisplayTarget target = config.routes.getOrDefault(
|
||||||
|
StreamChatConfig.routeKey(selectedPlatform, eventType), DisplayTarget.HIDDEN);
|
||||||
|
return Component.literal(eventType + ": " + target.name());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Component toggleLabel(Platform platform) {
|
||||||
|
return Component.literal(platform.name() + ": " + (config.isEnabled(platform) ? "On" : "Off"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,387 @@
|
|||||||
|
package dev.streamchat;
|
||||||
|
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import com.google.gson.JsonParser;
|
||||||
|
import net.minecraft.client.Minecraft;
|
||||||
|
import net.minecraft.client.gui.components.toasts.SystemToast;
|
||||||
|
import net.minecraft.network.chat.Component;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.WebSocket;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.EnumMap;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.CompletionStage;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.ScheduledExecutorService;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.logging.Level;
|
||||||
|
import java.util.logging.Logger;
|
||||||
|
|
||||||
|
public final class ConnectionManager {
|
||||||
|
private static final Logger LOGGER = Logger.getLogger("StreamChat");
|
||||||
|
private static final String[] TWITCH_EVENTS = {
|
||||||
|
"ChatMessage", "Follow", "Sub", "ReSub", "GiftSub", "Cheer", "Raid"
|
||||||
|
};
|
||||||
|
private static final String[] YOUTUBE_EVENTS = {
|
||||||
|
"Message", "SuperChat", "SuperSticker", "MemberMilestone",
|
||||||
|
"NewSponsor", "GiftMembershipReceived"
|
||||||
|
};
|
||||||
|
|
||||||
|
private final Minecraft client;
|
||||||
|
private final StreamChatConfig config;
|
||||||
|
private final HttpClient httpClient = HttpClient.newBuilder()
|
||||||
|
.connectTimeout(Duration.ofSeconds(10))
|
||||||
|
.build();
|
||||||
|
private final ScheduledExecutorService retryExecutor = Executors.newSingleThreadScheduledExecutor(task -> {
|
||||||
|
Thread thread = new Thread(task, "Stream Chat reconnect");
|
||||||
|
thread.setDaemon(true);
|
||||||
|
return thread;
|
||||||
|
});
|
||||||
|
private final Map<Platform, WebSocket> sockets = new EnumMap<>(Platform.class);
|
||||||
|
private final Map<Platform, String> statuses = new EnumMap<>(Platform.class);
|
||||||
|
private final Map<Platform, Instant> lastEvents = new EnumMap<>(Platform.class);
|
||||||
|
private final Map<Platform, Integer> retryCounts = new EnumMap<>(Platform.class);
|
||||||
|
private final java.util.Set<Platform> scheduledRetries = java.util.EnumSet.noneOf(Platform.class);
|
||||||
|
private boolean manuallyPaused;
|
||||||
|
private volatile long connectionGeneration;
|
||||||
|
private String authenticationRequestId;
|
||||||
|
|
||||||
|
public ConnectionManager(Minecraft client, StreamChatConfig config) {
|
||||||
|
this.client = client;
|
||||||
|
this.config = config;
|
||||||
|
for (Platform platform : Platform.values()) {
|
||||||
|
statuses.put(platform, "Disconnected");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized void start() {
|
||||||
|
manuallyPaused = false;
|
||||||
|
connectEnabledPlatforms();
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized void toggleConnections() {
|
||||||
|
manuallyPaused = !manuallyPaused;
|
||||||
|
if (manuallyPaused) {
|
||||||
|
closeAll();
|
||||||
|
} else {
|
||||||
|
connectEnabledPlatforms();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized void configurationChanged() {
|
||||||
|
if (!manuallyPaused) {
|
||||||
|
closeAll();
|
||||||
|
connectEnabledPlatforms();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized String status(Platform platform) {
|
||||||
|
return statuses.getOrDefault(platform, "Disconnected");
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized String lastEvent(Platform platform) {
|
||||||
|
Instant timestamp = lastEvents.get(platform);
|
||||||
|
return timestamp == null ? "Never" : timestamp.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void connectEnabledPlatforms() {
|
||||||
|
if (config.streamerBotEnabled && (config.twitchEnabled || config.youtubeEnabled)) {
|
||||||
|
connect(Platform.TWITCH, config.streamerBotUrl);
|
||||||
|
} else {
|
||||||
|
statuses.put(Platform.TWITCH, "Disabled");
|
||||||
|
statuses.put(Platform.YOUTUBE, "Disabled");
|
||||||
|
}
|
||||||
|
if (config.tikFinityEnabled && config.tiktokEnabled) {
|
||||||
|
connect(Platform.TIKTOK, config.tikFinityUrl);
|
||||||
|
} else {
|
||||||
|
statuses.put(Platform.TIKTOK, "Disabled");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private synchronized void connect(Platform platform, String address) {
|
||||||
|
long generation = connectionGeneration;
|
||||||
|
statuses.put(platform, "Connecting");
|
||||||
|
try {
|
||||||
|
httpClient.newWebSocketBuilder()
|
||||||
|
.connectTimeout(Duration.ofSeconds(10))
|
||||||
|
.buildAsync(URI.create(address), new Listener(platform, generation))
|
||||||
|
.whenComplete((socket, error) -> {
|
||||||
|
synchronized (ConnectionManager.this) {
|
||||||
|
if (generation != connectionGeneration) {
|
||||||
|
if (socket != null) socket.sendClose(WebSocket.NORMAL_CLOSURE, "Configuration changed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (error != null) {
|
||||||
|
statuses.put(platform, "Error: " + error.getMessage());
|
||||||
|
LOGGER.log(Level.WARNING, platform + " connection failed", error);
|
||||||
|
scheduleReconnect(platform, address, generation);
|
||||||
|
} else {
|
||||||
|
sockets.put(platform, socket);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (IllegalArgumentException exception) {
|
||||||
|
statuses.put(platform, "Error: " + exception.getMessage());
|
||||||
|
LOGGER.log(Level.WARNING, "Invalid WebSocket address for " + platform, exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private synchronized void closeAll() {
|
||||||
|
connectionGeneration++;
|
||||||
|
for (Map.Entry<Platform, WebSocket> entry : sockets.entrySet()) {
|
||||||
|
entry.getValue().sendClose(WebSocket.NORMAL_CLOSURE, "Disconnected by user");
|
||||||
|
statuses.put(entry.getKey(), "Disconnected");
|
||||||
|
if (entry.getKey() == Platform.TWITCH && config.youtubeEnabled) {
|
||||||
|
statuses.put(Platform.YOUTUBE, "Disconnected");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sockets.clear();
|
||||||
|
scheduledRetries.clear();
|
||||||
|
retryCounts.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendStreamerBotSubscription(WebSocket socket) {
|
||||||
|
JsonObject events = new JsonObject();
|
||||||
|
if (config.twitchEnabled) events.add("Twitch", strings(TWITCH_EVENTS));
|
||||||
|
if (config.youtubeEnabled) events.add("YouTube", strings(YOUTUBE_EVENTS));
|
||||||
|
JsonObject request = new JsonObject();
|
||||||
|
request.addProperty("request", "Subscribe");
|
||||||
|
request.addProperty("id", UUID.randomUUID().toString());
|
||||||
|
request.add("events", events);
|
||||||
|
socket.sendText(request.toString(), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private com.google.gson.JsonArray strings(String[] values) {
|
||||||
|
com.google.gson.JsonArray array = new com.google.gson.JsonArray();
|
||||||
|
for (String value : values) array.add(value);
|
||||||
|
return array;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void receive(Platform socketPlatform, String raw) {
|
||||||
|
try {
|
||||||
|
JsonObject root = JsonParser.parseString(raw).getAsJsonObject();
|
||||||
|
if (socketPlatform == Platform.TWITCH && handleStreamerBotControl(root)) return;
|
||||||
|
StreamEvent event = socketPlatform == Platform.TIKTOK
|
||||||
|
? StreamEventParser.parseTikFinity(raw)
|
||||||
|
: StreamEventParser.parseStreamerBot(raw);
|
||||||
|
synchronized (this) {
|
||||||
|
lastEvents.put(event.platform(), Instant.now());
|
||||||
|
statuses.put(event.platform(), "Connected");
|
||||||
|
}
|
||||||
|
if (!isKnownEvent(event)) {
|
||||||
|
LOGGER.warning("Unrecognized " + event.platform() + " event: " + raw);
|
||||||
|
}
|
||||||
|
if (!config.isEnabled(event.platform())) return;
|
||||||
|
DisplayTarget target = config.getRoute(event);
|
||||||
|
if (target != DisplayTarget.HIDDEN) {
|
||||||
|
client.execute(() -> display(event, target));
|
||||||
|
}
|
||||||
|
} catch (RuntimeException exception) {
|
||||||
|
LOGGER.log(Level.WARNING, "Could not parse event from " + socketPlatform + ": " + raw, exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private synchronized void scheduleReconnect(Platform platform, String address, long generation) {
|
||||||
|
if (generation != connectionGeneration || manuallyPaused || !isConnectionEnabled(platform)
|
||||||
|
|| !scheduledRetries.add(platform)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int attempts = retryCounts.merge(platform, 1, Integer::sum);
|
||||||
|
long delaySeconds = Math.min(30, 1L << Math.min(attempts, 5));
|
||||||
|
statuses.put(platform, "Reconnecting in " + delaySeconds + "s");
|
||||||
|
retryExecutor.schedule(() -> {
|
||||||
|
synchronized (ConnectionManager.this) {
|
||||||
|
scheduledRetries.remove(platform);
|
||||||
|
if (generation == connectionGeneration && !manuallyPaused && isConnectionEnabled(platform)) {
|
||||||
|
connect(platform, address);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, delaySeconds, TimeUnit.SECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isConnectionEnabled(Platform platform) {
|
||||||
|
return platform == Platform.TWITCH
|
||||||
|
? config.streamerBotEnabled && (config.twitchEnabled || config.youtubeEnabled)
|
||||||
|
: config.tikFinityEnabled && config.tiktokEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean handleStreamerBotControl(JsonObject root) {
|
||||||
|
String request = root.has("request") ? root.get("request").getAsString() : "";
|
||||||
|
if ("Hello".equalsIgnoreCase(request)) {
|
||||||
|
JsonObject authentication = root.has("authentication") && root.get("authentication").isJsonObject()
|
||||||
|
? root.getAsJsonObject("authentication") : new JsonObject();
|
||||||
|
String salt = value(authentication, "salt");
|
||||||
|
String challenge = value(authentication, "challenge");
|
||||||
|
if (!salt.isBlank() && !challenge.isBlank() && config.streamerBotPassword.isBlank()) {
|
||||||
|
statuses.put(Platform.TWITCH, "Error: Streamer.bot password required");
|
||||||
|
if (config.youtubeEnabled) statuses.put(Platform.YOUTUBE, "Error: Streamer.bot password required");
|
||||||
|
} else if (!config.streamerBotPassword.isBlank() && !salt.isBlank() && !challenge.isBlank()) {
|
||||||
|
JsonObject authRequest = new JsonObject();
|
||||||
|
authRequest.addProperty("request", "Authenticate");
|
||||||
|
authenticationRequestId = UUID.randomUUID().toString();
|
||||||
|
authRequest.addProperty("id", authenticationRequestId);
|
||||||
|
authRequest.addProperty("authentication", sha256(challenge + salt + config.streamerBotPassword));
|
||||||
|
WebSocket socket = sockets.get(Platform.TWITCH);
|
||||||
|
if (socket != null) socket.sendText(authRequest.toString(), true);
|
||||||
|
} else {
|
||||||
|
WebSocket socket = sockets.get(Platform.TWITCH);
|
||||||
|
if (socket != null) sendStreamerBotSubscription(socket);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (root.has("id") && root.has("status")) {
|
||||||
|
String status = value(root, "status");
|
||||||
|
if ("ok".equalsIgnoreCase(status)) {
|
||||||
|
synchronized (this) {
|
||||||
|
statuses.put(Platform.TWITCH, "Connected");
|
||||||
|
statuses.put(Platform.YOUTUBE, config.youtubeEnabled ? "Connected" : "Disabled");
|
||||||
|
}
|
||||||
|
if (authenticationRequestId != null
|
||||||
|
&& authenticationRequestId.equals(value(root, "id"))) {
|
||||||
|
authenticationRequestId = null;
|
||||||
|
WebSocket socket = sockets.get(Platform.TWITCH);
|
||||||
|
if (socket != null) sendStreamerBotSubscription(socket);
|
||||||
|
}
|
||||||
|
} else if (!status.isBlank()) {
|
||||||
|
String reason = value(root, "error");
|
||||||
|
synchronized (this) {
|
||||||
|
statuses.put(Platform.TWITCH, "Error: " + (reason.isBlank() ? status : reason));
|
||||||
|
if (config.youtubeEnabled) {
|
||||||
|
statuses.put(Platform.YOUTUBE, "Error: " + (reason.isBlank() ? status : reason));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LOGGER.warning("Streamer.bot request returned status: " + root);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (root.has("event")) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String sha256(String value) {
|
||||||
|
try {
|
||||||
|
byte[] digest = MessageDigest.getInstance("SHA-256")
|
||||||
|
.digest(value.getBytes(StandardCharsets.UTF_8));
|
||||||
|
StringBuilder result = new StringBuilder(digest.length * 2);
|
||||||
|
for (byte item : digest) result.append(String.format(Locale.ROOT, "%02x", item));
|
||||||
|
return result.toString();
|
||||||
|
} catch (NoSuchAlgorithmException exception) {
|
||||||
|
throw new IllegalStateException("SHA-256 is unavailable", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String value(JsonObject object, String key) {
|
||||||
|
return object.has(key) && !object.get(key).isJsonNull() ? object.get(key).getAsString() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isKnownEvent(StreamEvent event) {
|
||||||
|
if (event.platform() == Platform.TIKTOK) {
|
||||||
|
return switch (event.type()) {
|
||||||
|
case "chat", "gift", "share", "follow", "like", "roomUser", "subscribe" -> true;
|
||||||
|
default -> false;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return event.platform() == Platform.TWITCH
|
||||||
|
? contains(TWITCH_EVENTS, event.type())
|
||||||
|
: contains(YOUTUBE_EVENTS, event.type());
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean contains(String[] values, String target) {
|
||||||
|
for (String value : values) if (value.equalsIgnoreCase(target)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void display(StreamEvent event, DisplayTarget target) {
|
||||||
|
Component message = Component.literal(config.format(event));
|
||||||
|
switch (target) {
|
||||||
|
case CHAT -> client.gui.getChat().addClientSystemMessage(message);
|
||||||
|
case ACTION_BAR -> client.gui.setOverlayMessage(message, false);
|
||||||
|
case TOAST -> SystemToast.add(
|
||||||
|
client.getToastManager(),
|
||||||
|
new SystemToast.SystemToastId(),
|
||||||
|
Component.literal(event.platform() + " " + event.type()),
|
||||||
|
message);
|
||||||
|
case HIDDEN -> {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class Listener implements WebSocket.Listener {
|
||||||
|
private final Platform platform;
|
||||||
|
private final long generation;
|
||||||
|
private final StringBuilder fragments = new StringBuilder();
|
||||||
|
|
||||||
|
private Listener(Platform platform, long generation) {
|
||||||
|
this.platform = platform;
|
||||||
|
this.generation = generation;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onOpen(WebSocket webSocket) {
|
||||||
|
synchronized (ConnectionManager.this) {
|
||||||
|
if (generation != connectionGeneration) {
|
||||||
|
webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "Configuration changed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sockets.put(platform, webSocket);
|
||||||
|
boolean platformEnabled = platform == Platform.TIKTOK
|
||||||
|
? config.tiktokEnabled : config.twitchEnabled;
|
||||||
|
statuses.put(platform, platformEnabled ? "Connected" : "Disabled");
|
||||||
|
retryCounts.remove(platform);
|
||||||
|
scheduledRetries.remove(platform);
|
||||||
|
if (platform == Platform.TWITCH) {
|
||||||
|
statuses.put(Platform.YOUTUBE, config.youtubeEnabled ? "Connected" : "Disabled");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
webSocket.request(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CompletionStage<?> onText(WebSocket webSocket, CharSequence data, boolean last) {
|
||||||
|
if (generation != connectionGeneration) return null;
|
||||||
|
fragments.append(data);
|
||||||
|
if (last) {
|
||||||
|
receive(platform, fragments.toString());
|
||||||
|
fragments.setLength(0);
|
||||||
|
}
|
||||||
|
webSocket.request(1);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CompletionStage<?> onClose(WebSocket webSocket, int statusCode, String reason) {
|
||||||
|
synchronized (ConnectionManager.this) {
|
||||||
|
if (generation == connectionGeneration) {
|
||||||
|
sockets.remove(platform);
|
||||||
|
statuses.put(platform, "Disconnected");
|
||||||
|
if (platform == Platform.TWITCH && config.youtubeEnabled) {
|
||||||
|
statuses.put(Platform.YOUTUBE, "Disconnected");
|
||||||
|
}
|
||||||
|
scheduleReconnect(platform, platform == Platform.TWITCH
|
||||||
|
? config.streamerBotUrl : config.tikFinityUrl, generation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(WebSocket webSocket, Throwable error) {
|
||||||
|
synchronized (ConnectionManager.this) {
|
||||||
|
if (generation == connectionGeneration) {
|
||||||
|
statuses.put(platform, "Error: " + error.getMessage());
|
||||||
|
scheduleReconnect(platform, platform == Platform.TWITCH
|
||||||
|
? config.streamerBotUrl : config.tikFinityUrl, generation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LOGGER.log(Level.WARNING, platform + " WebSocket error", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package dev.streamchat;
|
||||||
|
|
||||||
|
import com.terraformersmc.modmenu.api.ConfigScreenFactory;
|
||||||
|
import com.terraformersmc.modmenu.api.ModMenuApi;
|
||||||
|
|
||||||
|
public final class ModMenuIntegration implements ModMenuApi {
|
||||||
|
@Override
|
||||||
|
public ConfigScreenFactory<?> getModConfigScreenFactory() {
|
||||||
|
return parent -> new StreamChatScreen(parent, StreamChatClient.CONFIG, StreamChatClient.CONNECTIONS);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package dev.streamchat;
|
||||||
|
|
||||||
|
import net.fabricmc.api.ClientModInitializer;
|
||||||
|
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
|
||||||
|
import net.fabricmc.fabric.api.client.keymapping.v1.KeyMappingHelper;
|
||||||
|
import net.minecraft.client.KeyMapping;
|
||||||
|
import net.minecraft.client.Minecraft;
|
||||||
|
import net.minecraft.resources.Identifier;
|
||||||
|
import org.lwjgl.glfw.GLFW;
|
||||||
|
|
||||||
|
public final class StreamChatClient implements ClientModInitializer {
|
||||||
|
private static final KeyMapping.Category CATEGORY =
|
||||||
|
KeyMapping.Category.register(Identifier.fromNamespaceAndPath("streamchat", "main"));
|
||||||
|
private static final KeyMapping TOGGLE_CONNECTIONS = KeyMappingHelper.registerKeyMapping(
|
||||||
|
new KeyMapping("key.streamchat.toggle_connections", GLFW.GLFW_KEY_UNKNOWN, CATEGORY));
|
||||||
|
|
||||||
|
public static StreamChatConfig CONFIG;
|
||||||
|
public static ConnectionManager CONNECTIONS;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onInitializeClient() {
|
||||||
|
CONFIG = StreamChatConfig.load();
|
||||||
|
CONNECTIONS = new ConnectionManager(Minecraft.getInstance(), CONFIG);
|
||||||
|
CONNECTIONS.start();
|
||||||
|
ClientTickEvents.END_CLIENT_TICK.register(client -> {
|
||||||
|
while (TOGGLE_CONNECTIONS.consumeClick()) {
|
||||||
|
CONNECTIONS.toggleConnections();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
package dev.streamchat;
|
||||||
|
|
||||||
|
import com.terraformersmc.modmenu.api.ConfigScreenFactory;
|
||||||
|
import net.minecraft.client.gui.GuiGraphicsExtractor;
|
||||||
|
import net.minecraft.client.gui.components.Button;
|
||||||
|
import net.minecraft.client.gui.components.EditBox;
|
||||||
|
import net.minecraft.client.gui.screens.Screen;
|
||||||
|
import net.minecraft.network.chat.Component;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public final class StreamChatScreen extends Screen {
|
||||||
|
private static final List<String> TWITCH_EVENTS =
|
||||||
|
List.of("ChatMessage", "Follow", "Sub", "ReSub", "GiftSub", "Cheer", "Raid");
|
||||||
|
private static final List<String> YOUTUBE_EVENTS =
|
||||||
|
List.of("Message", "SuperChat", "SuperSticker", "MemberMilestone", "NewSponsor", "GiftMembershipReceived");
|
||||||
|
private static final List<String> TIKTOK_EVENTS =
|
||||||
|
List.of("chat", "gift", "share", "follow", "like", "roomUser", "subscribe");
|
||||||
|
|
||||||
|
private final Screen parent;
|
||||||
|
private final StreamChatConfig config;
|
||||||
|
private final ConnectionManager connections;
|
||||||
|
private Platform selectedPlatform = Platform.TWITCH;
|
||||||
|
private EditBox streamerBotUrl;
|
||||||
|
private EditBox streamerBotPassword;
|
||||||
|
private EditBox tikFinityUrl;
|
||||||
|
private EditBox prefix;
|
||||||
|
private EditBox template;
|
||||||
|
|
||||||
|
public StreamChatScreen(Screen parent, StreamChatConfig config, ConnectionManager connections) {
|
||||||
|
super(Component.literal("Stream Chat Settings"));
|
||||||
|
this.parent = parent;
|
||||||
|
this.config = config;
|
||||||
|
this.connections = connections;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void init() {
|
||||||
|
int left = width / 2 - 190;
|
||||||
|
int rowWidth = 380;
|
||||||
|
int buttonWidth = 120;
|
||||||
|
|
||||||
|
addRenderableWidget(Button.builder(toggleLabel(Platform.TWITCH), button -> {
|
||||||
|
config.twitchEnabled = !config.twitchEnabled;
|
||||||
|
button.setMessage(toggleLabel(Platform.TWITCH));
|
||||||
|
}).bounds(left, 28, buttonWidth, 20).build());
|
||||||
|
addRenderableWidget(Button.builder(toggleLabel(Platform.YOUTUBE), button -> {
|
||||||
|
config.youtubeEnabled = !config.youtubeEnabled;
|
||||||
|
button.setMessage(toggleLabel(Platform.YOUTUBE));
|
||||||
|
}).bounds(left + 130, 28, buttonWidth, 20).build());
|
||||||
|
addRenderableWidget(Button.builder(toggleLabel(Platform.TIKTOK), button -> {
|
||||||
|
config.tiktokEnabled = !config.tiktokEnabled;
|
||||||
|
button.setMessage(toggleLabel(Platform.TIKTOK));
|
||||||
|
}).bounds(left + 260, 28, buttonWidth, 20).build());
|
||||||
|
|
||||||
|
streamerBotUrl = new EditBox(font, left, 75, rowWidth, 20, Component.literal("Streamer.bot WebSocket URL"));
|
||||||
|
streamerBotUrl.setValue(config.streamerBotUrl);
|
||||||
|
streamerBotUrl.setMaxLength(256);
|
||||||
|
addRenderableWidget(streamerBotUrl);
|
||||||
|
|
||||||
|
streamerBotPassword = new EditBox(font, left, 105, rowWidth, 20, Component.literal("Streamer.bot password"));
|
||||||
|
streamerBotPassword.setValue(config.streamerBotPassword);
|
||||||
|
streamerBotPassword.setMaxLength(256);
|
||||||
|
streamerBotPassword.setSuggestion("Streamer.bot password (stored in config file)");
|
||||||
|
addRenderableWidget(streamerBotPassword);
|
||||||
|
|
||||||
|
tikFinityUrl = new EditBox(font, left, 135, rowWidth, 20, Component.literal("TikFinity WebSocket URL"));
|
||||||
|
tikFinityUrl.setValue(config.tikFinityUrl);
|
||||||
|
tikFinityUrl.setMaxLength(256);
|
||||||
|
addRenderableWidget(tikFinityUrl);
|
||||||
|
|
||||||
|
addRenderableWidget(Button.builder(Component.literal("Streamer.bot: " + (config.streamerBotEnabled ? "On" : "Off")),
|
||||||
|
button -> {
|
||||||
|
config.streamerBotEnabled = !config.streamerBotEnabled;
|
||||||
|
button.setMessage(Component.literal("Streamer.bot: " + (config.streamerBotEnabled ? "On" : "Off")));
|
||||||
|
}).bounds(left, 165, 180, 20).build());
|
||||||
|
addRenderableWidget(Button.builder(Component.literal("TikFinity: " + (config.tikFinityEnabled ? "On" : "Off")),
|
||||||
|
button -> {
|
||||||
|
config.tikFinityEnabled = !config.tikFinityEnabled;
|
||||||
|
button.setMessage(Component.literal("TikFinity: " + (config.tikFinityEnabled ? "On" : "Off")));
|
||||||
|
}).bounds(left + 200, 165, 180, 20).build());
|
||||||
|
|
||||||
|
addRenderableWidget(Button.builder(Component.literal("Twitch"), button -> select(Platform.TWITCH))
|
||||||
|
.bounds(left, 200, 120, 20).build());
|
||||||
|
addRenderableWidget(Button.builder(Component.literal("YouTube"), button -> select(Platform.YOUTUBE))
|
||||||
|
.bounds(left + 130, 200, 120, 20).build());
|
||||||
|
addRenderableWidget(Button.builder(Component.literal("TikTok"), button -> select(Platform.TIKTOK))
|
||||||
|
.bounds(left + 260, 200, 120, 20).build());
|
||||||
|
|
||||||
|
prefix = new EditBox(font, left, 230, rowWidth, 20, Component.literal("Platform prefix"));
|
||||||
|
prefix.setValue(config.getPrefix(selectedPlatform));
|
||||||
|
prefix.setMaxLength(128);
|
||||||
|
addRenderableWidget(prefix);
|
||||||
|
template = new EditBox(font, left, 270, rowWidth, 20, Component.literal("Message format"));
|
||||||
|
template.setValue(config.templates.getOrDefault(selectedPlatform.name(),
|
||||||
|
"{prefix}{username} > {message}"));
|
||||||
|
template.setMaxLength(256);
|
||||||
|
addRenderableWidget(template);
|
||||||
|
addRouteButtons(left);
|
||||||
|
|
||||||
|
addRenderableWidget(Button.builder(Component.literal("Toggle connections"), button -> connections.toggleConnections())
|
||||||
|
.bounds(left, height - 32, 180, 20).build());
|
||||||
|
addRenderableWidget(Button.builder(Component.literal("Done"), button -> onClose())
|
||||||
|
.bounds(left + 200, height - 32, 180, 20).build());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void extractRenderState(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float delta) {
|
||||||
|
extractMenuBackground(graphics);
|
||||||
|
graphics.centeredText(font, title, width / 2, 8, 0xFFFFFF);
|
||||||
|
int left = width / 2 - 190;
|
||||||
|
graphics.text(font, "Streamer.bot / TikFinity WebSocket settings", left, 60, 0xA0A0A0);
|
||||||
|
graphics.text(font, "Connection status: " + connections.status(selectedPlatform), left, 188, 0xFFFFFF);
|
||||||
|
graphics.text(font, "Last event: " + connections.lastEvent(selectedPlatform), left + 180, 188, 0xA0A0A0);
|
||||||
|
graphics.text(font, "Prefix", left, 220, 0xA0A0A0);
|
||||||
|
graphics.text(font, "Format: {prefix}, {platform}, {event}, {username}, {message}", left, 256, 0xA0A0A0);
|
||||||
|
graphics.text(font, "Event destination (click to cycle)", left, 296, 0xA0A0A0);
|
||||||
|
super.extractRenderState(graphics, mouseX, mouseY, delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onClose() {
|
||||||
|
config.streamerBotUrl = streamerBotUrl.getValue().trim();
|
||||||
|
config.streamerBotPassword = streamerBotPassword.getValue();
|
||||||
|
config.tikFinityUrl = tikFinityUrl.getValue().trim();
|
||||||
|
config.prefixes.put(selectedPlatform.name(), prefix.getValue());
|
||||||
|
config.templates.put(selectedPlatform.name(), template.getValue());
|
||||||
|
config.save();
|
||||||
|
connections.configurationChanged();
|
||||||
|
minecraft.setScreen(parent);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void select(Platform platform) {
|
||||||
|
config.streamerBotUrl = streamerBotUrl.getValue().trim();
|
||||||
|
config.streamerBotPassword = streamerBotPassword.getValue();
|
||||||
|
config.tikFinityUrl = tikFinityUrl.getValue().trim();
|
||||||
|
config.prefixes.put(selectedPlatform.name(), prefix.getValue());
|
||||||
|
config.templates.put(selectedPlatform.name(), template.getValue());
|
||||||
|
selectedPlatform = platform;
|
||||||
|
prefix.setValue(config.getPrefix(platform));
|
||||||
|
template.setValue(config.templates.getOrDefault(platform.name(),
|
||||||
|
"{prefix}{username} > {message}"));
|
||||||
|
rebuildRoutes();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void rebuildRoutes() {
|
||||||
|
clearWidgets();
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addRouteButtons(int left) {
|
||||||
|
List<String> events = switch (selectedPlatform) {
|
||||||
|
case TWITCH -> TWITCH_EVENTS;
|
||||||
|
case YOUTUBE -> YOUTUBE_EVENTS;
|
||||||
|
case TIKTOK -> TIKTOK_EVENTS;
|
||||||
|
};
|
||||||
|
for (int i = 0; i < events.size(); i++) {
|
||||||
|
String eventType = events.get(i);
|
||||||
|
int x = left + (i % 2) * 195;
|
||||||
|
int y = 320 + (i / 2) * 24;
|
||||||
|
addRenderableWidget(Button.builder(routeLabel(eventType), button -> {
|
||||||
|
String key = StreamChatConfig.routeKey(selectedPlatform, eventType);
|
||||||
|
DisplayTarget[] targets = DisplayTarget.values();
|
||||||
|
DisplayTarget current = config.routes.getOrDefault(key, DisplayTarget.HIDDEN);
|
||||||
|
config.routes.put(key, targets[(current.ordinal() + 1) % targets.length]);
|
||||||
|
button.setMessage(routeLabel(eventType));
|
||||||
|
}).bounds(x, y, 185, 20).build());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Component routeLabel(String eventType) {
|
||||||
|
DisplayTarget target = config.routes.getOrDefault(
|
||||||
|
StreamChatConfig.routeKey(selectedPlatform, eventType), DisplayTarget.HIDDEN);
|
||||||
|
return Component.literal(eventType + ": " + target.name());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Component toggleLabel(Platform platform) {
|
||||||
|
return Component.literal(platform.name() + ": " + (config.isEnabled(platform) ? "On" : "Off"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user