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:
minster586
2026-10-26 05:14:12 -04:00
parent 514423725e
commit 90666d0ac9
25 changed files with 1999 additions and 1 deletions
@@ -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"));
}
}