using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; using System.Reflection; using System.Speech.Synthesis; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; // Mr. Operator v3.2.0 // Streamer.bot editor reference required: // System.Speech.dll // // Runtime dependency: // \dlls\CRNTLY.StreamerBot.UI.dll // // Mr. Operator owns its switchboard layout and behavior. CRNTLY supplies the // shared visual system and controls, along with WPF hosting and script bridges. public static class MrOperatorBuild { public const string ProductName = "Mr. Operator"; public const string Version = "3.2.0"; public const string RewardName = "Call In"; public const string PhoneEmoteName = "Phone"; public const string HangUpEmoteName = "Hangup"; public static MrOperatorCallEntryMode CallEntryMode = MrOperatorCallEntryMode.Both; public static bool UsesPhoneEmoteCalls { get { return CallEntryMode == MrOperatorCallEntryMode.PhoneEmote || CallEntryMode == MrOperatorCallEntryMode.Both; } } public static bool UsesCallInReward { get { return CallEntryMode == MrOperatorCallEntryMode.CallInReward || CallEntryMode == MrOperatorCallEntryMode.Both; } } } public enum MrOperatorCallEntryMode { PhoneEmote, CallInReward, Both } public enum MrOperatorChatCommand { None, RequestCall, HangUp } public static class MrOperatorChatCommandParser { public static MrOperatorChatCommand Parse(string message) { if (string.IsNullOrWhiteSpace(message)) return MrOperatorChatCommand.None; var emote = message.Trim(); if (string.Equals(emote, MrOperatorBuild.PhoneEmoteName, StringComparison.OrdinalIgnoreCase)) return MrOperatorChatCommand.RequestCall; if (string.Equals(emote, MrOperatorBuild.HangUpEmoteName, StringComparison.OrdinalIgnoreCase)) return MrOperatorChatCommand.HangUp; return MrOperatorChatCommand.None; } } public class CPHInline { private MrOperatorRuntime _runtime; public void Init() { } public bool Execute() { try { // One Streamer.bot Action can own both Twitch triggers. Keep the // event routing here so it needs only this Execute C# Code block. var eventType = Convert.ToString(CPH.GetEventType(), CultureInfo.InvariantCulture); if (string.Equals(eventType, "TwitchRewardRedemption", StringComparison.Ordinal)) return RewardRedeemed(); if (string.Equals(eventType, "TwitchChatMessage", StringComparison.Ordinal)) return HandleChatMessage(); if (_runtime != null) { if (!_runtime.IsDisposed) { _runtime.Show(); return true; } _runtime = null; } Action log = message => CPH.LogInfo("[CRNTLY " + MrOperatorBuild.ProductName + "] " + message); Action logError = message => CPH.LogError("[CRNTLY " + MrOperatorBuild.ProductName + "] " + message); string rewardId = null; if (MrOperatorBuild.UsesCallInReward) { try { rewardId = FindCallInRewardId(); } catch (Exception ex) { logError("Could not check for the optional Call In reward: " + ex); } } if (!MrOperatorBuild.UsesPhoneEmoteCalls && !MrOperatorBuild.UsesCallInReward) { CPH.SendMessage("Mr. Operator has no call method selected. Set MrOperatorBuild.CallEntryMode to PhoneEmote, CallInReward, or Both.", false); return false; } if (MrOperatorBuild.UsesCallInReward && string.IsNullOrEmpty(rewardId) && !MrOperatorBuild.UsesPhoneEmoteCalls) { CPH.SendMessage("Mr. Operator needs a Twitch Channel Point reward named '" + MrOperatorBuild.RewardName + "' for CallInReward mode.", false); return false; } if (MrOperatorBuild.UsesCallInReward && string.IsNullOrEmpty(rewardId)) log("No Call In reward was found. Phone emote calls remain available in Both mode."); else if (!MrOperatorBuild.UsesCallInReward) log("Call In reward support is disabled. Phone emote calls are enabled."); MrOperatorScriptWindowProxy window; if (!MrOperatorDependencyBootstrap.TryCreateScriptWindow(log, logError, out window)) return false; _runtime = new MrOperatorRuntime( rewardId, window, message => CPH.SendMessage(message, false), id => CPH.EnableReward(id), id => CPH.DisableReward(id), log, logError); _runtime.Start(); CPH.SendMessage("Mr. Operator is ready. Open lines to accept callers using the configured call method.", false); return true; } catch (Exception ex) { CPH.LogError("[CRNTLY " + MrOperatorBuild.ProductName + "] Startup failed: " + ex); CPH.SendMessage("Mr. Operator could not start: " + ex.Message, false); if (_runtime != null) { _runtime.Dispose(); _runtime = null; } return false; } } public bool RewardRedeemed() { if (_runtime == null || _runtime.IsDisposed) return false; return _runtime.HandleRewardRedemption( GetArgument("userName"), GetArgument("rewardName")); } public bool HandleChatMessage() { if (_runtime == null || _runtime.IsDisposed) return false; var message = GetArgument("rawInput", "message"); return _runtime.HandleChatMessage(GetArgument("userName"), message); } public void Dispose() { if (_runtime == null) return; _runtime.Dispose(); _runtime = null; } private string FindCallInRewardId() { var rewards = CPH.TwitchGetRewards(); if (rewards == null) return null; foreach (var reward in rewards) { if (reward != null && string.Equals(Convert.ToString(reward.Title), MrOperatorBuild.RewardName, StringComparison.OrdinalIgnoreCase)) { return Convert.ToString(reward.Id); } } return null; } private string GetArgument(params string[] keys) { foreach (var key in keys) { try { object value; if (CPH.TryGetArg(key, out value) && value != null) return Convert.ToString(value, CultureInfo.InvariantCulture); } catch { // The alternate name may not exist for this trigger type. } } return string.Empty; } } public static class MrOperatorDependencyBootstrap { private const string DllName = "CRNTLY.StreamerBot.UI.dll"; private const string BridgeTypeName = "Crntly.StreamerBot.UI.ScriptHost.CrntlyScriptWindowBridge"; public static bool TryCreateScriptWindow( Action log, Action logError, out MrOperatorScriptWindowProxy window) { window = null; try { var dllPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "dlls", DllName); if (!File.Exists(dllPath)) { var message = MrOperatorBuild.ProductName + " v" + MrOperatorBuild.Version + " needs the shared CRNTLY UI component.\n\n" + "Expected:\n" + dllPath + "\n\n" + "Deploy CRNTLY.StreamerBot.UI.dll to Streamer.bot\\dlls, restart Streamer.bot, and run this action again."; ShowBootstrapMessage(message, "CRNTLY " + MrOperatorBuild.ProductName + " - Component Missing"); if (logError != null) logError("Missing UI component: " + dllPath); return false; } var assembly = FindLoadedAssembly() ?? Assembly.LoadFrom(dllPath); var bridgeType = assembly.GetType(BridgeTypeName, false); if (bridgeType == null) throw new InvalidOperationException( "The installed CRNTLY UI component does not contain " + BridgeTypeName + ". Deploy the current CRNTLY.StreamerBot.UI.dll and restart Streamer.bot."); var bridge = Activator.CreateInstance(bridgeType); window = new MrOperatorScriptWindowProxy(bridgeType, bridge); if (log != null) log("Loaded CRNTLY UI " + window.AssemblyVersion + " from " + dllPath); return true; } catch (Exception ex) { var message = MrOperatorBuild.ProductName + " v" + MrOperatorBuild.Version + " could not load the CRNTLY UI component.\n\n" + ex.Message + "\n\n" + "Deploy the current CRNTLY.StreamerBot.UI.dll to Streamer.bot\\dlls, restart Streamer.bot, and run this action again."; ShowBootstrapMessage(message, "CRNTLY " + MrOperatorBuild.ProductName + " - Load Error"); if (logError != null) logError("Unable to load CRNTLY UI: " + ex); return false; } } private static Assembly FindLoadedAssembly() { foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()) { try { if (string.Equals(assembly.GetName().Name, "CRNTLY.StreamerBot.UI", StringComparison.OrdinalIgnoreCase)) return assembly; } catch { } } return null; } private static void ShowBootstrapMessage(string message, string title) { try { var type = Type.GetType("System.Windows.Forms.MessageBox, System.Windows.Forms", false); if (type == null) return; var method = type.GetMethod("Show", new[] { typeof(string), typeof(string) }); if (method != null) method.Invoke(null, new object[] { message, title }); } catch { } } } public sealed class MrOperatorScriptWindowProxy : IDisposable { private readonly Type _bridgeType; private readonly object _bridge; private readonly MethodInfo _show; private readonly MethodInfo _hide; private readonly MethodInfo _bindEvent; private readonly MethodInfo _bindRoutedEvent; private readonly MethodInfo _getProperty; private readonly MethodInfo _setProperty; private readonly MethodInfo _setResourceProperty; private readonly MethodInfo _setItemsSource; private readonly MethodInfo _invokeMethod; private readonly MethodInfo _dispose; public MrOperatorScriptWindowProxy(Type bridgeType, object bridge) { _bridgeType = bridgeType; _bridge = bridge; _show = RequireMethod("Show", typeof(string)); _hide = RequireMethod("Hide"); _bindEvent = RequireMethod("BindEvent", typeof(string), typeof(string), typeof(string)); _bindRoutedEvent = RequireMethod("BindRoutedEvent", typeof(string), typeof(string), typeof(string)); _getProperty = RequireMethod("GetProperty", typeof(string), typeof(string)); _setProperty = RequireMethod("SetProperty", typeof(string), typeof(string), typeof(object)); _setResourceProperty = RequireMethod("SetResourceProperty", typeof(string), typeof(string), typeof(string)); _setItemsSource = RequireMethod("SetItemsSource", typeof(string), typeof(object)); _invokeMethod = RequireMethod("InvokeMethod", typeof(string), typeof(string)); _dispose = RequireMethod("Dispose"); var hideOnClose = _bridgeType.GetProperty("HideOnUserClose"); if (hideOnClose != null && hideOnClose.CanWrite) hideOnClose.SetValue(_bridge, true, null); } public string AssemblyVersion { get { var property = _bridgeType.GetProperty("AssemblyVersion"); return property == null ? "unknown" : Convert.ToString(property.GetValue(_bridge, null), CultureInfo.InvariantCulture); } } public Action EventRaised { set { SetCallback("EventRaised", value); } } public Action RoutedEventRaised { set { SetCallback("RoutedEventRaised", value); } } public void Show(string xaml) { _show.Invoke(_bridge, new object[] { xaml }); } public void Hide() { _hide.Invoke(_bridge, null); } public void BindEvent(string controlName, string eventName, string eventKey) { _bindEvent.Invoke(_bridge, new object[] { controlName, eventName, eventKey }); } public void BindRoutedEvent(string ownerTypeName, string routedEventFieldName, string eventKey) { _bindRoutedEvent.Invoke(_bridge, new object[] { ownerTypeName, routedEventFieldName, eventKey }); } public object GetProperty(string controlName, string propertyName) { return _getProperty.Invoke(_bridge, new object[] { controlName, propertyName }); } public T Get(string controlName, string propertyName, T fallback) { var value = GetProperty(controlName, propertyName); if (value == null) return fallback; try { if (value is T) return (T)value; return (T)Convert.ChangeType(value, typeof(T), CultureInfo.InvariantCulture); } catch { return fallback; } } public void SetProperty(string controlName, string propertyName, object value) { _setProperty.Invoke(_bridge, new object[] { controlName, propertyName, value }); } public void SetResourceProperty(string controlName, string propertyName, string resourceKey) { _setResourceProperty.Invoke(_bridge, new object[] { controlName, propertyName, resourceKey }); } public void SetItemsSource(string controlName, object items) { _setItemsSource.Invoke(_bridge, new object[] { controlName, items }); } public object InvokeMethod(string controlName, string methodName) { return _invokeMethod.Invoke(_bridge, new object[] { controlName, methodName }); } private MethodInfo RequireMethod(string name, params Type[] parameterTypes) { var method = _bridgeType.GetMethod(name, parameterTypes); if (method == null) throw new MissingMethodException(_bridgeType.FullName, name); return method; } private void SetCallback(string propertyName, object callback) { var property = _bridgeType.GetProperty(propertyName); if (property == null || !property.CanWrite) throw new MissingMemberException(_bridgeType.FullName, propertyName); property.SetValue(_bridge, callback, null); } public void Dispose() { try { EventRaised = null; RoutedEventRaised = null; _dispose.Invoke(_bridge, null); } catch { } } } public sealed class MrOperatorLineCard { public int LineNumber { get; set; } public string LineLabel { get; set; } public string CallerId { get; set; } public string CallerName { get; set; } public string Initials { get; set; } public string StateLabel { get; set; } public string Subtitle { get; set; } public string ActionToolTip { get; set; } public bool IsActive { get; set; } public bool IsWaiting { get; set; } public bool IsActionEnabled { get; set; } public bool IsOpen { get; set; } public bool IsEmpty { get; set; } } public sealed class MrOperatorRuntime : IDisposable { private const int LineCount = 9; private const string WindowXaml = @" 6 12 8 M6.62,10.79 C8.06,13.62 10.38,15.94 13.21,17.38 L15.41,15.18 C15.68,14.91 16.08,14.82 16.43,14.94 C17.55,15.31 18.75,15.51 20,15.51 C20.55,15.51 21,15.96 21,16.51 V20 C21,20.55 20.55,21 20,21 C10.61,21 3,13.39 3,4 C3,3.45 3.45,3 4,3 H7.5 C8.05,3 8.5,3.45 8.5,4 C8.5,5.25 8.7,6.45 9.07,7.57 C9.18,7.92 9.1,8.31 8.82,8.59 Z M11,5 H13 V11 H19 V13 H13 V19 H11 V13 H5 V11 H11 Z M12,2 L21,6 V11 C21,16.5 17.2,20.4 12,22 C6.8,20.4 3,16.5 3,11 V6 Z M12,4.2 L5,7.3 V11 C5,15.3 7.8,18.4 12,19.8 C16.2,18.4 19,15.3 19,11 V7.3 Z M11,8 H13 V13 H11 Z M11,15 H13 V17 H11 Z M4,11 H16.2 L10.6,5.4 L12,4 L20,12 L12,20 L10.6,18.6 L16.2,13 H4 Z "; private readonly object _stateGate = new object(); private readonly object _speechGate = new object(); private readonly SemaphoreSlim _rewardSyncGate = new SemaphoreSlim(1, 1); private readonly List _queuedCallers = new List(); private readonly List _voiceNames = new List(); private readonly string _rewardId; private readonly MrOperatorScriptWindowProxy _window; private readonly Action _sendMessage; private readonly Action _log; private readonly Action _logError; private readonly Action _enableReward; private readonly Action _disableReward; private readonly System.Threading.Timer _callTimer; private SpeechSynthesizer _synth; private string _currentCaller; private string _selectedVoice; private string _notice = "Open lines to accept callers. Use ANSWER and HANG UP on the line."; private List _filteredWords = new List(); private DateTime _callStartTime; private int _activeLine; private int _volume = 80; private bool _linesOpen; private bool _muted; private bool _filterEnabled; private bool _started; private bool _disposed; private long _rewardVersion; public MrOperatorRuntime( string rewardId, MrOperatorScriptWindowProxy window, Action sendMessage, Action enableReward, Action disableReward, Action log, Action logError) { _rewardId = rewardId; _window = window; _sendMessage = sendMessage; _enableReward = enableReward; _disableReward = disableReward; _log = log; _logError = logError; _callTimer = new System.Threading.Timer(OnCallTimer, null, Timeout.Infinite, Timeout.Infinite); } public bool IsDisposed { get { lock (_stateGate) return _disposed; } } public void Start() { lock (_stateGate) { if (_disposed) throw new ObjectDisposedException(nameof(MrOperatorRuntime)); if (_started) return; _started = true; } InitializeSpeech(); _window.EventRaised = HandleUiEvent; _window.RoutedEventRaised = HandleRoutedUiEvent; _window.Show(WindowXaml); _window.SetProperty("$window", "Title", MrOperatorBuild.ProductName); _window.SetProperty("VersionText", "Text", MrOperatorBuild.ProductName + " v" + MrOperatorBuild.Version + " ยท UI " + _window.AssemblyVersion); _window.SetItemsSource("VoiceSelect", _voiceNames); lock (_stateGate) _selectedVoice = _voiceNames.Count == 0 ? null : _voiceNames[0]; if (_voiceNames.Count > 0) _window.SetProperty("VoiceSelect", "SelectedIndex", 0); BindUiEvents(); Render(); long initialRewardVersion; lock (_stateGate) initialRewardVersion = _rewardVersion; RequestRewardSync(false, initialRewardVersion, false); } public void Show() { if (IsDisposed) return; if (!_started) Start(); _window.Show(WindowXaml); Render(); } private void InitializeSpeech() { try { _synth = new SpeechSynthesizer(); _synth.SetOutputToDefaultAudioDevice(); foreach (var voice in _synth.GetInstalledVoices()) { if (voice != null && voice.Enabled && voice.VoiceInfo != null && !string.IsNullOrWhiteSpace(voice.VoiceInfo.Name)) _voiceNames.Add(voice.VoiceInfo.Name); } } catch (Exception ex) { _synth = null; _notice = "Windows speech output is unavailable. Switchboard controls are still active."; if (_logError != null) _logError("Speech synthesis initialization failed: " + ex); } } private void BindUiEvents() { _window.BindEvent("ToggleLinesButton", "Click", "toggle-lines"); _window.BindEvent("MinimizeButton", "Click", "minimize-window"); _window.BindEvent("CloseButton", "Click", "close-panel"); _window.BindEvent("$window", "Closing", "window-closing"); _window.BindEvent("MuteToggle", "Checked", "mute-on"); _window.BindEvent("MuteToggle", "Unchecked", "mute-off"); _window.BindEvent("FilterToggle", "Checked", "filter-on"); _window.BindEvent("FilterToggle", "Unchecked", "filter-off"); _window.BindEvent("FilterWordsBox", "TextChanged", "filter-text"); _window.BindEvent("VolumeSlider", "ValueChanged", "volume-changed"); _window.BindEvent("VoiceSelect", "SelectionChanged", "voice-changed"); _window.BindRoutedEvent( "System.Windows.Controls.Primitives.ButtonBase, PresentationFramework", "ClickEvent", "line-click"); } private void HandleUiEvent(string eventKey) { try { switch (eventKey) { case "toggle-lines": ToggleLines(); break; case "minimize-window": _window.SetProperty("$window", "WindowState", "Minimized"); break; case "close-panel": case "window-closing": ClosePanel(); break; case "mute-on": SetMuted(true); break; case "mute-off": SetMuted(false); break; case "filter-on": SetFilterEnabled(true); break; case "filter-off": SetFilterEnabled(false); break; case "filter-text": ReadFilterText(); break; case "volume-changed": ReadVolume(); break; case "voice-changed": ReadVoice(); break; } } catch (Exception ex) { if (_logError != null) _logError("UI event '" + eventKey + "' failed: " + ex); SetNotice("That control could not be updated. Check the Streamer.bot log."); } } private void HandleRoutedUiEvent(string eventKey, object dataContext) { if (!string.Equals(eventKey, "line-click", StringComparison.Ordinal)) return; var line = dataContext as MrOperatorLineCard; if (line != null) SelectLine(line); } private void ToggleLines() { bool desiredState; long version; lock (_stateGate) { if (_disposed) return; _linesOpen = !_linesOpen; desiredState = _linesOpen; version = ++_rewardVersion; _notice = desiredState ? "Lines are opening. " + JoinInstructionText() : "Lines are closing. The active call can finish."; } Render(); RequestRewardSync(desiredState, version, true); } private string JoinInstructionText() { var phoneEnabled = MrOperatorBuild.UsesPhoneEmoteCalls; var rewardAvailable = MrOperatorBuild.UsesCallInReward && !string.IsNullOrWhiteSpace(_rewardId); if (phoneEnabled && rewardAvailable) return "Send the Phone emote by itself or redeem Call In to join."; if (phoneEnabled) return "Send the Phone emote by itself in chat to join."; if (rewardAvailable) return "Redeem Call In to join."; return "No call method is available."; } private string CompactJoinInstructionText() { var phoneEnabled = MrOperatorBuild.UsesPhoneEmoteCalls; var rewardAvailable = MrOperatorBuild.UsesCallInReward && !string.IsNullOrWhiteSpace(_rewardId); if (phoneEnabled && rewardAvailable) return "Send Phone or redeem Call In"; if (phoneEnabled) return "Send Phone emote to join"; if (rewardAvailable) return "Redeem Call In to join"; return "No call method configured"; } private void RequestRewardSync(bool desiredState, long version, bool announce) { Task.Run(() => { _rewardSyncGate.Wait(); try { lock (_stateGate) { if (_disposed || version != _rewardVersion || desiredState != _linesOpen) return; } if (!string.IsNullOrWhiteSpace(_rewardId)) { if (desiredState) _enableReward(_rewardId); else _disableReward(_rewardId); } bool stillCurrent; lock (_stateGate) { stillCurrent = !_disposed && version == _rewardVersion && desiredState == _linesOpen; if (stillCurrent) _notice = desiredState ? (string.IsNullOrWhiteSpace(_rewardId) ? "Lines are open. " + JoinInstructionText() : "Call In is enabled. " + JoinInstructionText()) : (string.IsNullOrWhiteSpace(_rewardId) ? "Lines are closed. The active call can finish." : "Call In is disabled. The active call can finish."); } if (stillCurrent && announce && _sendMessage != null) { try { _sendMessage(desiredState ? "๐Ÿ“ž Switchboard is OPEN! " + JoinInstructionText() : "๐Ÿ“ž Switchboard is CLOSED."); } catch (Exception ex) { if (_logError != null) _logError("Unable to announce the switchboard state: " + ex); } } if (stillCurrent) Render(); } catch (Exception ex) { bool shouldRender = false; lock (_stateGate) { if (!_disposed && version == _rewardVersion) { if (desiredState) { _notice = string.IsNullOrWhiteSpace(_rewardId) ? "Lines are open. " + JoinInstructionText() : "Phone calls are open, but Call In could not be enabled. Check Twitch and Streamer.bot."; } else { _notice = "Lines are closed, but Call In could not be disabled. Check Twitch and Streamer.bot."; } shouldRender = true; } } if (_logError != null) _logError("Unable to sync Call In reward state: " + ex); if (shouldRender) Render(); } finally { _rewardSyncGate.Release(); } }); } public bool HandleRewardRedemption(string userName, string rewardName) { if (!MrOperatorBuild.UsesCallInReward || string.IsNullOrWhiteSpace(userName) || !string.Equals(rewardName, MrOperatorBuild.RewardName, StringComparison.OrdinalIgnoreCase)) return false; return JoinCallQueue(userName); } private bool JoinCallQueue(string userName) { if (string.IsNullOrWhiteSpace(userName)) return false; string response = null; bool accepted = false; lock (_stateGate) { if (_disposed) return false; if (!_linesOpen) { response = "@" + userName + " the switchboard is closed. Wait for the lines to open before calling."; } else { var duplicateIndex = _queuedCallers.FindIndex( caller => string.Equals(caller, userName, StringComparison.OrdinalIgnoreCase)); if (duplicateIndex >= 0) { response = "@" + userName + " you're already waiting at position #" + (duplicateIndex + 1) + "."; } else if (string.Equals(_currentCaller, userName, StringComparison.OrdinalIgnoreCase)) { response = "@" + userName + " you're already on the line."; } else if (_queuedCallers.Count + (_currentCaller == null ? 0 : 1) >= LineCount) { response = "@" + userName + " the switchboard is full right now. Please try again later."; } else { _queuedCallers.Add(userName); _notice = "@" + userName + " joined the queue."; response = "๐Ÿ“ž @" + userName + " joined the queue! Position #" + _queuedCallers.Count + "."; accepted = true; } } } if (_sendMessage != null && response != null) { try { _sendMessage(response); } catch (Exception ex) { if (_logError != null) _logError("Unable to announce the caller queue update: " + ex); } } if (accepted) Render(); return accepted; } public bool HandleChatMessage(string userName, string message) { if (string.IsNullOrWhiteSpace(userName) || string.IsNullOrWhiteSpace(message)) return false; var command = MrOperatorChatCommandParser.Parse(message); if (command == MrOperatorChatCommand.RequestCall) return MrOperatorBuild.UsesPhoneEmoteCalls && JoinCallQueue(userName); if (command == MrOperatorChatCommand.HangUp) return HandleHangUpEmote(userName); string caller; string selectedVoice; string[] words; int volume; lock (_stateGate) { if (_disposed || _currentCaller == null || !string.Equals(userName, _currentCaller, StringComparison.OrdinalIgnoreCase) || _muted) return false; caller = _currentCaller; selectedVoice = _selectedVoice; volume = _volume; words = _filterEnabled ? _filteredWords.ToArray() : new string[0]; } var spokenMessage = message; foreach (var word in words) { if (string.IsNullOrWhiteSpace(word)) continue; spokenMessage = Regex.Replace( spokenMessage, Regex.Escape(word), match => new string('*', match.Length), RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); } Speak(spokenMessage, selectedVoice, volume, false); if (_log != null) _log("Speaking a chat message from the active caller " + caller + "."); return true; } private bool HandleHangUpEmote(string userName) { string endedCaller = null; TimeSpan callDuration = TimeSpan.Zero; string queueMessage = null; lock (_stateGate) { if (_disposed) return false; if (string.Equals(_currentCaller, userName, StringComparison.OrdinalIgnoreCase)) { endedCaller = _currentCaller; callDuration = DateTime.UtcNow - _callStartTime; _currentCaller = null; _activeLine = 0; _callTimer.Change(Timeout.Infinite, Timeout.Infinite); _notice = "The call with @" + endedCaller + " has ended."; } else { var queueIndex = _queuedCallers.FindIndex( caller => string.Equals(caller, userName, StringComparison.OrdinalIgnoreCase)); if (queueIndex < 0) return false; _queuedCallers.RemoveAt(queueIndex); _notice = "@" + userName + " left the call queue."; queueMessage = "๐Ÿ“ž @" + userName + " left the call queue."; } } Render(); if (endedCaller != null) { AnnounceCallEnded(endedCaller, callDuration); if (_log != null) _log("The active caller " + endedCaller + " ended the call with the Hangup emote."); } else if (queueMessage != null && _sendMessage != null) { try { _sendMessage(queueMessage); } catch (Exception ex) { if (_logError != null) _logError("Unable to announce a caller leaving the queue: " + ex); } } return true; } private void SelectLine(MrOperatorLineCard line) { if (line == null || line.IsEmpty) return; string connectedCaller = null; string endedCaller = null; TimeSpan callDuration = TimeSpan.Zero; bool startedCall = false; bool endedCall = false; lock (_stateGate) { if (_disposed) return; if (_currentCaller != null) { if (line.IsActive && line.LineNumber == _activeLine) { endedCaller = _currentCaller; callDuration = DateTime.UtcNow - _callStartTime; _currentCaller = null; _activeLine = 0; _callTimer.Change(Timeout.Infinite, Timeout.Infinite); _notice = "The call with @" + endedCaller + " has ended."; endedCall = true; } else { _notice = "Finish the active call before connecting another caller."; } } else { var queueIndex = _queuedCallers.FindIndex( caller => string.Equals(caller, line.CallerId, StringComparison.OrdinalIgnoreCase)); if (queueIndex >= 0) { connectedCaller = _queuedCallers[queueIndex]; _queuedCallers.RemoveAt(queueIndex); _currentCaller = connectedCaller; _activeLine = line.LineNumber; _callStartTime = DateTime.UtcNow; _callTimer.Change(0, 1000); _notice = "@" + connectedCaller + " is live. Their chat is read aloud; send Hangup or click the active line to end."; startedCall = true; } else { _notice = "Line " + line.LineNumber.ToString("00", CultureInfo.InvariantCulture) + " is open. " + JoinInstructionText(); } } } Render(); if (startedCall) { StopSpeech(); var caller = connectedCaller; var lineNumber = line.LineNumber; Task.Run(() => { try { if (_sendMessage != null) _sendMessage("๐Ÿ“ž Line " + lineNumber + " connected to @" + caller + "! Type in chat to speak."); Speak(caller + " is now on line " + lineNumber + ".", null, -1, false); } catch (Exception ex) { if (_logError != null) _logError("Unable to announce the active caller: " + ex); } }); } else if (endedCall) { AnnounceCallEnded(endedCaller, callDuration); } } private void AnnounceCallEnded(string caller, TimeSpan callDuration) { StopSpeech(); var duration = FormatDuration(callDuration); Task.Run(() => { try { if (_sendMessage != null) _sendMessage("๐Ÿ“ž Call ended with @" + caller + " (Duration: " + duration + "). Thanks for calling!"); Speak("Call ended.", null, -1, false); } catch (Exception ex) { if (_logError != null) _logError("Unable to announce the call ending: " + ex); } }); } private void ClosePanel() { string endedCaller; int waitingCount; long version; lock (_stateGate) { if (_disposed) return; endedCaller = _currentCaller; waitingCount = _queuedCallers.Count; _currentCaller = null; _activeLine = 0; _queuedCallers.Clear(); _linesOpen = false; _callTimer.Change(Timeout.Infinite, Timeout.Infinite); _notice = "Switchboard stopped. Reopen the panel and lines to take more calls."; version = ++_rewardVersion; } StopSpeech(); Render(); RequestRewardSync(false, version, false); try { _window.Hide(); } catch { } string closingMessage = null; if (endedCaller != null) closingMessage = "๐Ÿ“ž The switchboard closed. The call with @" + endedCaller + " has ended."; else if (waitingCount > 0) closingMessage = "๐Ÿ“ž The switchboard closed and cleared " + waitingCount + " waiting caller(s)."; if (closingMessage != null && _sendMessage != null) { Task.Run(() => { try { _sendMessage(closingMessage); } catch (Exception ex) { if (_logError != null) _logError("Unable to announce switchboard shutdown: " + ex); } }); } } private void SetMuted(bool muted) { lock (_stateGate) { if (_disposed) return; _muted = muted; _notice = muted ? "Caller speech is muted." : "Caller speech is unmuted."; } if (muted) StopSpeech(); Render(); } private void SetFilterEnabled(bool enabled) { lock (_stateGate) { if (_disposed) return; _filterEnabled = enabled; _notice = enabled ? "The word filter is enabled for caller speech." : "The word filter is disabled."; } Render(); } private void ReadFilterText() { var text = _window.Get("FilterWordsBox", "Text", string.Empty); var words = text .Split(new[] { ',', ';', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries) .Select(word => word.Trim()) .Where(word => word.Length > 0) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); lock (_stateGate) { if (_disposed) return; _filteredWords = words; } } private void ReadVolume() { var rawValue = _window.Get("VolumeSlider", "Value", 80.0); var volume = Math.Max(0, Math.Min(100, (int)Math.Round(rawValue))); lock (_stateGate) { if (_disposed) return; _volume = volume; } try { _window.SetProperty("VolumeValueText", "Text", volume.ToString(CultureInfo.InvariantCulture) + "%"); } catch { } } private void ReadVoice() { var selected = _window.GetProperty("VoiceSelect", "SelectedItem"); var voice = selected == null ? null : Convert.ToString(selected, CultureInfo.InvariantCulture); lock (_stateGate) { if (_disposed) return; _selectedVoice = voice; } } private void SetNotice(string notice) { lock (_stateGate) { if (_disposed) return; _notice = notice; } Render(); } private void Render() { MrOperatorLineCard[] lines; string currentCaller; string notice; string duration; string callMeta; int waitingCount; int activeLine; int connectedCount; bool linesOpen; lock (_stateGate) { if (_disposed || !_started) return; currentCaller = _currentCaller; waitingCount = _queuedCallers.Count; activeLine = _activeLine; connectedCount = waitingCount + (currentCaller == null ? 0 : 1); linesOpen = _linesOpen; notice = _notice; duration = currentCaller == null ? "00:00" : FormatDuration(DateTime.UtcNow - _callStartTime); callMeta = currentCaller == null ? (waitingCount == 0 ? "Waiting for the first caller" : "Click a waiting line to connect") : "Connected on Line " + activeLine.ToString("00", CultureInfo.InvariantCulture) + " ยท click or send Hangup to end"; var assigned = new Dictionary(); if (currentCaller != null && activeLine >= 1 && activeLine <= LineCount) assigned[activeLine] = CreateLineCard(activeLine, currentCaller, true, false, 0, linesOpen, false); var queuePosition = 0; for (var i = 0; i < _queuedCallers.Count; i++) { var slot = 1; while (slot <= LineCount && assigned.ContainsKey(slot)) slot++; if (slot > LineCount) break; queuePosition++; assigned[slot] = CreateLineCard(slot, _queuedCallers[i], false, true, queuePosition, linesOpen, currentCaller == null); } var list = new List(LineCount); for (var lineNumber = 1; lineNumber <= LineCount; lineNumber++) { MrOperatorLineCard card; if (assigned.TryGetValue(lineNumber, out card)) list.Add(card); else list.Add(CreateLineCard(lineNumber, null, false, false, 0, linesOpen, false)); } lines = list.ToArray(); } try { _window.SetItemsSource("LineGrid", lines); _window.SetProperty("TitleStateText", "Text", linesOpen ? "LINES OPEN" : "LINES CLOSED"); _window.SetResourceProperty("TitleStateDot", "Fill", linesOpen ? "Crntly.Success" : "Crntly.TextSubtle"); _window.SetResourceProperty("TitleStateText", "Foreground", linesOpen ? "Crntly.Success" : "Crntly.TextSubtle"); _window.SetProperty("ToggleLinesButton", "Content", linesOpen ? "CLOSE LINES" : "OPEN LINES"); _window.SetResourceProperty("ToggleLinesButton", "Style", linesOpen ? "Crntly.DangerButton" : "Crntly.PrimaryButton"); _window.SetProperty("QueueCountText", "Text", waitingCount.ToString(CultureInfo.InvariantCulture)); _window.SetProperty("CapacityText", "Text", connectedCount.ToString(CultureInfo.InvariantCulture) + " / " + LineCount.ToString(CultureInfo.InvariantCulture)); _window.SetProperty("CallStateText", "Text", currentCaller == null ? "STANDBY" : "ON AIR"); _window.SetResourceProperty("CallStateDot", "Fill", currentCaller == null ? "Crntly.TextSubtle" : "Crntly.Success"); _window.SetResourceProperty("CallStateText", "Foreground", currentCaller == null ? "Crntly.TextSubtle" : "Crntly.Success"); _window.SetResourceProperty("CurrentCallCard", "BorderBrush", currentCaller == null ? "Crntly.Border" : "Crntly.Success"); _window.SetResourceProperty("CurrentCallCard", "Background", currentCaller == null ? "Crntly.Surface" : "Crntly.SuccessMuted"); _window.SetProperty("CurrentCallerInitials", "Text", currentCaller == null ? string.Empty : GetInitials(currentCaller)); _window.SetProperty("CurrentCallerInitials", "Visibility", currentCaller == null ? "Collapsed" : "Visible"); _window.SetProperty("CurrentCallerIcon", "Visibility", currentCaller == null ? "Visible" : "Collapsed"); _window.SetResourceProperty("CurrentCallerBadge", "Background", currentCaller == null ? "Crntly.AccentMuted" : "Crntly.SuccessMuted"); _window.SetResourceProperty("CurrentCallerBadge", "BorderBrush", currentCaller == null ? "Crntly.Accent" : "Crntly.Success"); _window.SetResourceProperty("CurrentCallerIcon", "Fill", currentCaller == null ? "Crntly.AccentHover" : "Crntly.Success"); _window.SetResourceProperty("CurrentCallerInitials", "Foreground", currentCaller == null ? "Crntly.Text" : "Crntly.Success"); _window.SetProperty("CurrentCallerText", "Text", currentCaller == null ? "No caller on the air" : "@" + currentCaller); _window.SetProperty("CurrentCallMetaText", "Text", callMeta); _window.SetProperty("CallDurationText", "Text", duration); _window.SetResourceProperty("CallDurationText", "Foreground", currentCaller == null ? "Crntly.AccentHover" : "Crntly.Success"); _window.SetProperty("NoticeText", "Text", notice); } catch (Exception ex) { if (_logError != null) _logError("Unable to refresh the switchboard window: " + ex); } } private MrOperatorLineCard CreateLineCard( int lineNumber, string caller, bool active, bool waiting, int queuePosition, bool linesOpen, bool canAnswer) { var label = "LINE " + lineNumber.ToString("00", CultureInfo.InvariantCulture); if (active) { return new MrOperatorLineCard { LineNumber = lineNumber, LineLabel = label, CallerId = caller, CallerName = "Call on air", Initials = "LIVE", StateLabel = "ACTIVE", Subtitle = "Click or send Hangup to end", ActionToolTip = "End the active call, or let the caller send Hangup", IsActive = true, IsWaiting = false, IsActionEnabled = true, IsOpen = false }; } if (waiting) { return new MrOperatorLineCard { LineNumber = lineNumber, LineLabel = label, CallerId = caller, CallerName = "@" + caller, Initials = GetInitials(caller), StateLabel = "WAITING", Subtitle = "Queue #" + queuePosition.ToString(CultureInfo.InvariantCulture), ActionToolTip = canAnswer ? "Answer this caller" : "Finish the active call before answering", IsActive = false, IsWaiting = true, IsActionEnabled = canAnswer, IsOpen = false }; } return new MrOperatorLineCard { LineNumber = lineNumber, LineLabel = label, CallerId = null, CallerName = linesOpen ? "Ready for callers" : "Line standing by", Initials = string.Empty, StateLabel = linesOpen ? "READY" : "STANDBY", Subtitle = linesOpen ? CompactJoinInstructionText() : "Open lines to accept callers", IsActive = false, IsWaiting = false, IsActionEnabled = false, IsOpen = linesOpen, IsEmpty = true }; } private static string GetInitials(string name) { if (string.IsNullOrWhiteSpace(name)) return "โ€”"; var cleaned = name.Trim().TrimStart('@'); if (cleaned.Length <= 2) return cleaned.ToUpperInvariant(); return cleaned.Substring(0, 2).ToUpperInvariant(); } private void OnCallTimer(object state) { if (IsDisposed) return; string duration; lock (_stateGate) { if (_disposed || _currentCaller == null) return; duration = FormatDuration(DateTime.UtcNow - _callStartTime); } try { _window.SetProperty("CallDurationText", "Text", duration); } catch { } } private void Speak(string text, string voiceName, int volume, bool cancelCurrent) { if (string.IsNullOrWhiteSpace(text)) return; if (volume < 0 || string.IsNullOrWhiteSpace(voiceName)) { lock (_stateGate) { if (volume < 0) volume = _volume; if (string.IsNullOrWhiteSpace(voiceName)) voiceName = _selectedVoice; } } var selectedVoice = voiceName; var selectedVolume = Math.Max(0, Math.Min(100, volume)); lock (_speechGate) { if (IsDisposed || _synth == null) return; try { if (cancelCurrent) _synth.SpeakAsyncCancelAll(); _synth.Volume = selectedVolume; if (!string.IsNullOrWhiteSpace(selectedVoice)) { try { _synth.SelectVoice(selectedVoice); } catch { } } _synth.SpeakAsync(text); } catch (Exception ex) { if (_logError != null) _logError("Speech output failed: " + ex.Message); } } } private void StopSpeech() { lock (_speechGate) { try { if (_synth != null) _synth.SpeakAsyncCancelAll(); } catch { } } } private static string FormatDuration(TimeSpan duration) { if (duration.TotalHours >= 1) return ((int)duration.TotalHours).ToString(CultureInfo.InvariantCulture) + ":" + duration.Minutes.ToString("00", CultureInfo.InvariantCulture) + ":" + duration.Seconds.ToString("00", CultureInfo.InvariantCulture); return ((int)duration.TotalMinutes).ToString("00", CultureInfo.InvariantCulture) + ":" + duration.Seconds.ToString("00", CultureInfo.InvariantCulture); } public void Dispose() { lock (_stateGate) { if (_disposed) return; _disposed = true; _linesOpen = false; _currentCaller = null; _queuedCallers.Clear(); _activeLine = 0; _rewardVersion++; } _callTimer.Dispose(); StopSpeech(); lock (_speechGate) { try { if (_synth != null) { _synth.Dispose(); _synth = null; } } catch { } } if (!string.IsNullOrWhiteSpace(_rewardId)) { Task.Run(() => { _rewardSyncGate.Wait(); try { _disableReward(_rewardId); } catch (Exception ex) { if (_logError != null) _logError("Unable to disable the Call In reward during shutdown: " + ex); } finally { _rewardSyncGate.Release(); } }); } if (_window != null) _window.Dispose(); } }