using System;
using System.Security.Cryptography;
using System.Text;
using JordanMod.Modules.BetterBugle;
using Newtonsoft.Json;
using Photon.Pun;
using UnityEngine;
namespace JordanMod.Modules.BingBongVoice;
[Module(Enabled = true)]
class BingBongVoiceModule : Module
{
public static BingBongVoiceModule? Instance { get; private set; }
public override string ModuleName => "BingBong Voice Module";
public BingBongVoiceClient? Client { get; private set; }
private GameObject? _debugObject;
private const string ChannelSalt = "jordanmod-bingbong-voice-v1";
public override Type[] GetPatches()
{
// Config is bound before modules load (Plugin.Awake: ConfigHandler.Initialize ->
// SetupModules -> SetupPatches), so turning the feature off here means the Harmony
// patches are never applied at all and the mod has zero footprint.
if (!ConfigHandler.BingBongVoiceEnabled.Value) return [];
return [typeof(BingBongVoicePatch)];
}
public override void Initialize()
{
if (Instance != null) return;
Instance = this;
// The relay lives on the audio bank, so with no override configured every player derives
// the same URL and connects on their own -- nobody has to press anything.
PhotonNetworkEventListener listener = PhotonNetworkEventListener.Instance!;
if (listener != null)
{
listener.RegisterOnJoinedRoom(OnJoinedRoom);
listener.RegisterOnLeftRoom(Disconnect);
listener.RegisterOnDisconnected(_ => Disconnect());
}
else
{
Debug.LogWarning("[BingBongVoice] No network event listener, auto-connect is unavailable.");
}
base.Initialize();
}
private void OnJoinedRoom()
{
if (!ConfigHandler.BingBongVoiceEnabled.Value) return;
if (!ConfigHandler.BingBongVoiceAutoConnect.Value) return;
Connect();
}
public override void Update()
{
if (!ConfigHandler.BingBongVoiceEnabled.Value) return;
if (Input.GetKeyDown(ConfigHandler.BingBongVoiceDebugKey.Value)) ToggleStream();
}
///
/// Where the relay lives. The relay is served by the audio bank, so an unset override just
/// derives the WebSocket URL from BugleSoundAPIURL; setting it points somewhere else
/// entirely (a local stub, a separate host).
///
public static string ResolveRelayUrl()
{
string overrideUrl = (ConfigHandler.BingBongVoiceRelayURL.Value ?? "").Trim();
string source;
string url;
if (overrideUrl.Length > 0)
{
// An override names the endpoint. Only fill in /voice/ws when it has no path at all,
// so "localhost:8787" works but "wss://host/custom/path" is left alone.
source = overrideUrl;
url = NormalizeOverride(overrideUrl);
}
else
{
// The audio bank URL names a service root, which may itself sit under a path
// (https://audiobank.thejordan.dev/api), so the endpoint always goes on the end.
source = (ConfigHandler.BugleSoundAPIURL.Value ?? "").Trim();
if (source.Length == 0)
{
Debug.LogWarning("[BingBongVoice] Neither RelayURL nor BugleSoundAPIURL is set, nowhere to connect.");
return "";
}
url = DeriveFromApiBase(source);
}
if (url.Length == 0)
{
Debug.LogError($"[BingBongVoice] Could not make a usable WebSocket URL out of '{source}'. Expected something like 'ws://localhost:8787/voice/ws' or just 'localhost:8787'.");
return "";
}
// A URL that already carries a token is left exactly as given -- that is how you point at
// a stub relay, or connect from outside a lobby.
if (url.IndexOf("token=", StringComparison.OrdinalIgnoreCase) < 0)
{
string channel = ResolveChannel();
if (channel.Length == 0)
{
Debug.LogWarning("[BingBongVoice] Not in a lobby, so there is no channel to join. Put a full URL including ?token= in RelayURL to connect from outside one.");
return "";
}
// The relay's published contract is "listen:". It also accepts a bare channel
// id, but following the documented form is what stays correct if it ever tightens.
// The channel must be lowercase hex -- uppercase is rejected with a 1008.
url += (url.IndexOf('?') >= 0 ? "&" : "?") + "token=" + Uri.EscapeDataString("listen:" + channel);
}
return url;
}
/// Announced to the relay on connect so admins can see which lobbies are live and who is in
/// them. Deliberately carries the player name but NOT the room code: the name is what makes a
/// session recognisable in the web app, whereas the code is what someone would need to join
/// the game, so there is no reason to put it on the wire.
private static string BuildHello()
{
string nickname = PhotonNetwork.LocalPlayer?.NickName ?? "";
if (string.IsNullOrWhiteSpace(nickname)) nickname = "Unknown";
return JsonConvert.SerializeObject(new { type = "hello", player = nickname });
}
///
/// Scopes the voice channel to the Photon lobby, so each game session gets its own and
/// players only hear whoever is speaking into theirs.
///
/// Hashed rather than sent as-is: the room code is what someone would need to join the game,
/// and it would otherwise sit in the URL and in every server access log. The salt is baked
/// into the mod so it is not a secret -- it only stops a channel id from being trivially
/// reversible into a joinable room code. Once the relay's own join endpoint exists it should
/// issue channel ids server-side instead, with a salt that really is private.
///
public static string ResolveChannel()
{
string room = PhotonNetwork.CurrentRoom?.Name ?? "";
if (string.IsNullOrWhiteSpace(room)) return "";
using SHA256 sha = SHA256.Create();
byte[] hash = sha.ComputeHash(Encoding.UTF8.GetBytes(ChannelSalt + room.Trim().ToUpperInvariant()));
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant().Substring(0, 32);
}
private static string NormalizeOverride(string raw)
{
string url = ToWebSocketScheme(raw);
// Split the query/fragment off first so appending a path can't mangle them.
int split = url.IndexOfAny(['?', '#']);
string tail = split >= 0 ? url.Substring(split) : "";
string head = split >= 0 ? url.Substring(0, split) : url;
if (!IsWebSocketUrl(head, out Uri? parsed)) return "";
// A bare host means the caller gave us the root, not the endpoint.
if (parsed.AbsolutePath.Length <= 1) head = head.TrimEnd('/') + "/voice/ws";
return head + tail;
}
private static string DeriveFromApiBase(string apiBase)
{
string url = ToWebSocketScheme(apiBase);
// The base is a service root, so anything after it is not ours to keep.
int split = url.IndexOfAny(['?', '#']);
if (split >= 0) url = url.Substring(0, split);
url = url.TrimEnd('/') + "/voice/ws";
return IsWebSocketUrl(url, out _) ? url : "";
}
private static bool IsWebSocketUrl(string url, out Uri parsed)
{
parsed = null!;
if (!Uri.TryCreate(url, UriKind.Absolute, out Uri? candidate)) return false;
if (candidate.Scheme != "ws" && candidate.Scheme != "wss") return false;
parsed = candidate;
return true;
}
private static string ToWebSocketScheme(string url)
{
if (url.StartsWith("ws://", StringComparison.OrdinalIgnoreCase)) return url;
if (url.StartsWith("wss://", StringComparison.OrdinalIgnoreCase)) return url;
if (url.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) return "wss://" + url.Substring(8);
if (url.StartsWith("http://", StringComparison.OrdinalIgnoreCase)) return "ws://" + url.Substring(7);
// No scheme at all: same rule as everywhere else, loopback gets the insecure variant.
return Helper.EnsureScheme(url, "wss", "ws");
}
// Every BingBong attaches its own source through BingBongVoicePatch; the optional debug
// source is the stage 1 harness, for testing with no BingBong in the scene.
private void ToggleStream()
{
if (Client != null) Disconnect();
else Connect();
}
private void Connect()
{
if (Client != null) return;
string url = ResolveRelayUrl();
if (url.Length == 0) return;
Debug.Log($"[BingBongVoice] Connecting for lobby '{PhotonNetwork.CurrentRoom?.Name}' to {url}");
Client = new BingBongVoiceClient();
Client.OnSpeakerChanged += AnnounceSpeaker;
Client.Connect(url, BuildHello());
if (!ConfigHandler.BingBongVoiceDebugSource.Value) return;
_debugObject = new GameObject("BingBongVoiceDebug");
UnityEngine.Object.DontDestroyOnLoad(_debugObject);
BingBongVoiceSource.Attach(_debugObject, null);
}
// Reuses the Bugle's actionbar because it is the only on-screen text this mod has. Raised on
// the main thread by the client, so touching Unity here is safe.
private static void AnnounceSpeaker(string? speaker)
{
if (!ConfigHandler.BingBongVoiceAnnounceSpeaker.Value) return;
BetterBugleUI.Instance?.ShowActionbar(speaker != null
? $"🎙 BingBong is being puppeted by {speaker}"
: "BingBong is himself again");
}
private void Disconnect()
{
if (Client == null) return;
Client.OnSpeakerChanged -= AnnounceSpeaker;
Debug.Log("[BingBongVoice] Disconnecting from relay.");
Client.Disconnect();
Client = null;
if (_debugObject != null) UnityEngine.Object.Destroy(_debugObject);
_debugObject = null;
}
public override void Destroy()
{
PhotonNetworkEventListener listener = PhotonNetworkEventListener.Instance!;
if (listener != null)
{
listener.JoinedRoom -= OnJoinedRoom;
listener.LeftRoom -= Disconnect;
}
Client?.Disconnect();
Client = null;
if (_debugObject != null) UnityEngine.Object.Destroy(_debugObject);
_debugObject = null;
base.Destroy();
}
}