commit 52b69601625cac061b0a9faee0e82bde279f75c9 Author: liyunze <50455574+liyunze-coding@users.noreply.github.com> Date: Wed Feb 18 15:30:10 2026 +1100 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a9d34b8 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +notes.md \ No newline at end of file diff --git a/Code.cs b/Code.cs new file mode 100644 index 0000000..be3a26a --- /dev/null +++ b/Code.cs @@ -0,0 +1,1135 @@ +using Streamer.bot.Plugin.Interface; +using Streamer.bot.Plugin.Interface.Model; +using Streamer.bot.Plugin.Interface.Enums; +using Streamer.bot.Common.Events; +using System; +using System.Collections.Generic; +using System.Text; +using System.Text.RegularExpressions; +using System.Linq; +using System.Reflection; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +/* +Todo + +delete based on days + +{ + platform-userId: { + username: string + tasks: [ + name + completed? + timestamp added + timestamp completed + ], + totalIncompleteCount: uint + totalCompletedCount: uint + } + task-data +} +*/ +class Task +{ + public string Name; + public bool Completed; + public bool Focused; + public DateTime AddedTime; + public DateTime UpdatedTime; + public DateTime? CompletedTime; + public Task(string Name, bool Completed = false, bool Focused = false) + { + this.Name = Name; + this.Completed = Completed; + this.Focused = Focused; + this.AddedTime = DateTime.Now; + this.UpdatedTime = DateTime.Now; + } +} + +class UserData +{ + public string username; + public List tasks; + public uint totalIncompleteCount; + public uint totalCompletedCount; + public UserData(List tasks, string username) + { + this.username = username; + this.tasks = tasks; + this.totalIncompleteCount = 0; + this.totalCompletedCount = 0; + } +} + +public class Response +{ + public bool Success { get; set; } + public string ErrorMsg { get; set; } + + public Response(bool success, string errorMsg = null) + { + this.Success = success; + this.ErrorMsg = errorMsg; + } +} + +public class Response : Response +{ + public T Data { get; set; } + + public Response(bool success, T data = default(T), string errorMsg = null) : base(success, errorMsg) + { + this.Data = data; + } +} + +public class CPHInline +{ + private int characterLimit = 450; + private Dictionary taskData = new Dictionary(); + public char[] separators = + { + '|', + ',', + ';' + }; + public void Init() + { + // task list + string taskDataString = CPH.GetGlobalVar("rython-task-bot", true); + Dictionary taskListData = JsonConvert.DeserializeObject>(taskDataString); + this.taskData = taskListData; + Cleanup(false); + } + + // Send changes to frontend + private void Broadcast(T t, string? key = null) + { + // INDEX MUST BE TRUE INDEX + string json = JsonConvert.SerializeObject(new { source = "rython-task-bot", id = key ?? GetKey(), body = t, username = GetUsername() }); + CPH.WebsocketBroadcastJson(json); + } + + private List GetStreamerUsernames() + { + TwitchUserInfo twitchInfo = CPH.TwitchGetBroadcaster(); + var youtubeInfo = CPH.YouTubeGetBroadcaster(); + List usernames = new() + { + twitchInfo.UserName, + youtubeInfo.UserName + }; + return usernames; + } + + private string GetUsername(string? key = null) + { + if (String.IsNullOrEmpty(key)) + { + CPH.TryGetArg("user", out string username); + return username; + } + else + { + return this.taskData[key].username; + } + + return ""; + } + + private void SaveTasks() + { + string taskDataString = JsonConvert.SerializeObject(this.taskData); + CPH.SetGlobalVar("rython-task-bot", taskDataString, true); + } + + private void SaveIntoTasks(List tasks) + { + string key = GetKey(); + this.taskData[key].username = GetUsername(); + this.taskData[key].tasks = tasks; + SaveTasks(); + } + + private void Respond(string message) + { + CPH.TryGetArg("userType", out string platform); + switch (platform) + { + case "twitch": + CPH.TryGetArg("msgId", out string msgId); + CPH.TwitchReplyToMessage(message, msgId); + break; + case "youtube": + CPH.TryGetArg("user", out string YTUser); + CPH.SendYouTubeMessage($"@{YTUser} {message}"); + break; + default: + // idk i don't stream elsewhere + break; + } + } + + private List SplitTasks(string tasks) + { + return tasks.Split(separators, StringSplitOptions.RemoveEmptyEntries).Select(t => t.Trim()).ToList(); + } + + private string GetKey(string? username = null) + { + if (!String.IsNullOrEmpty(username)) + { + if (username[0] == '@') + { + username = username.Substring(1); + } + + // dig through task data, find username, case insensitive + foreach (var item in this.taskData) + { + if (item.Value.username.Equals(username, StringComparison.OrdinalIgnoreCase)) + { + return item.Key; + } + } + + return ""; + } + else + { + CPH.TryGetArg("userType", out string platform); + CPH.TryGetArg("userId", out string userId); + string key = $"{platform}-{userId}"; + return key; + } + } + + // ADD COMMAND + // <(true index, task name)> + private Response<(int, string)> AddTask(string taskName, bool completed = false, bool focused = false) + { + taskName = taskName.Trim(); + // Validation + if (string.IsNullOrEmpty(taskName)) + return new Response<(int, string)>(false, default, "Task cannot be empty"); + if (int.TryParse(taskName, out _)) + return new Response<(int, string)>(false, default, $"'{taskName}' cannot be a number"); + // Ensure user data exists + string key = GetKey(); + if (!this.taskData.ContainsKey(key)) + { + string username = GetUsername(); + this.taskData.Add(key, new UserData(new List(), username)); + } + + // Check for duplicate incomplete task + if (this.taskData[key].tasks.Any(t => t.Name.Equals(taskName, StringComparison.OrdinalIgnoreCase) && !t.Completed)) + return new Response<(int, string)>(false, default, $"Error: Task '{taskName}' already exists"); + // Add task + this.taskData[key].username = GetUsername(); + this.taskData[key].tasks.Add(new Task(taskName, completed, focused)); + int newIndex = this.taskData[key].tasks.Count - 1; // 1-based index for display + Broadcast(new { mode = "add", task = taskName, completed = completed, focused = focused }); + return new Response<(int, string)>(true, (newIndex, taskName), null); + } + + public bool AddCommand() + { + CPH.TryGetArg("rawInput", out string rawInput); + List taskStrings = SplitTasks(rawInput); + if (taskStrings.Count == 0) + { + Respond("No tasks provided"); + return false; + } + + List added = new(); + List<(string, string)> failed = new(); + foreach (string taskString in taskStrings) + { + var response = AddTask(taskString); + if (response.Success) + added.Add($"{response.Data.Item1 + 1}. {taskString.Trim()}"); + else + failed.Add((taskString, response.ErrorMsg)); + } + + // Save once after all tasks are processed + if (added.Count > 0) + { + SaveTasks(); + } + + // Build response + Respond(BuildAddResponseMessage(added, failed)); + return true; + } + + public bool LogCommand() + { + CPH.TryGetArg("rawInput", out string rawInput); + List taskStrings = SplitTasks(rawInput); + if (taskStrings.Count == 0) + { + Respond("Error: No tasks provided"); + return false; + } + + List logged = new(); + List<(string, string)> failed = new(); + foreach (string taskString in taskStrings) + { + var response = AddTask(taskString, true, false); + if (response.Success) + logged.Add($"{response.Data.Item1 + 1}. {taskString.Trim()}"); + else + failed.Add((taskString, response.ErrorMsg)); + } + + // Save once after all tasks are processed + if (logged.Count > 0) + { + SaveTasks(); + } + + // Build response + Respond(BuildLogResponseMessage(logged, failed)); + return true; + } + + private string BuildAddResponseMessage(List added, List<(string, string)> failed) + { + string response = "No tasks to add"; + if (added.Count > 0 && failed.Count == 0) + { + response = $"Added: {String.Join(" | ", added)}"; + if (response.Length > characterLimit) + { + response = "All the tasks have been added!"; + } + } + else if (added.Count == 0 && failed.Count == 1) + { + response = failed[0].Item2; // Single error, show the reason + } + else if (added.Count == 0 && failed.Count > 1) + { + response = $"Failed: {String.Join(", ", failed.Select(f => f.Item1))}"; + if (response.Length > characterLimit) + { + response = "None of the tasks successfully added"; + } + } + else if (added.Count > 0 && failed.Count > 0) + { + response = $"Added: {String.Join(" | ", added)} | Failed: {String.Join(", ", failed.Select(f => f.Item1))}"; + if (response.Length > characterLimit) + { + response = "Some tasks successful but some failed :p"; + } + } + + return response; + } + + private string BuildLogResponseMessage(List added, List<(string, string)> failed) + { + string response = "No tasks to add"; + if (added.Count > 0 && failed.Count == 0) + { + response = $"Added: {String.Join(" | ", added)}"; + if (response.Length > characterLimit) + { + response = "All the tasks have been added!"; + } + } + else if (added.Count == 0 && failed.Count == 1) + { + response = failed[0].Item2; // Single error, show the reason + } + else if (added.Count == 0 && failed.Count > 1) + { + response = $"Failed: {String.Join(", ", failed.Select(f => f.Item1))}"; + if (response.Length > characterLimit) + { + response = "None of the tasks successfully added"; + } + } + else if (added.Count > 0 && failed.Count > 0) + { + response = $"Added: {String.Join(" | ", added)} | Failed: {String.Join(", ", failed.Select(f => f.Item1))}"; + if (response.Length > characterLimit) + { + response = "Some tasks successful but some failed :p"; + } + } + + return response; + } + + // focus on existing task, or add a new task and focus on it + private Response<(int, string)> FocusOnTask(string rawInput) + { + // make sure there's no separators + bool containsSeparators = rawInput.IndexOfAny(separators) >= 0; + if (containsSeparators) + { + return new(false, default, "Cannot focus on multiple tasks"); + } + + List tasks = ListUserTasks(); + int IndexByName = tasks.FindIndex(t => t.Name.Equals(rawInput, StringComparison.OrdinalIgnoreCase)); + // existing task by name, new task, or index + if (int.TryParse(rawInput, out int n)) + { + n = n - 1; // true index + // IT'S A NUMBER + if (n < 0 || n >= tasks.Count) + { + return new(false, default, "Index out of range."); + } + else if (tasks[n].Completed) + { + // task already completed, cannot focus on it + return new(false, default, "Cannot focus on completed task."); + } + else + { + // unfocus on every single task + // then focus on that task + for (int i = 0; i < tasks.Count; i++) + { + tasks[i].Focused = false; + } + + tasks[n].Focused = true; + SaveIntoTasks(tasks); + Broadcast(new { mode = "focus", index = n }); + return new(true, (n, tasks[n].Name), null); + } + } + else if (IndexByName > -1) + { + // it's an existing task + n = IndexByName; + if (tasks[n].Completed) + { + // task already completed, cannot focus on it + return new(false, default, "Cannot focus on completed task."); + } + + for (int i = 0; i < tasks.Count; i++) + { + tasks[i].Focused = false; + } + + tasks[n].Focused = true; + SaveIntoTasks(tasks); + Broadcast(new { mode = "focus", index = n }); + return new(true, (n, tasks[n].Name), null); + } + else + { + // new task + var response = AddTask(rawInput, false, true); + if (response.Success) + { + Broadcast(new { mode = "focus", index = response.Data.Item1 }); + return new(true, (response.Data.Item1, response.Data.Item2), null); + } + else + { + return new(false, default, $"{response.ErrorMsg}"); + } + } + } + + // FOCUS COMMAND + // 1: focus on existing task by name (check if already focused) + // 2: focus on existing task by index (must be valid index that isn't already focused) + // 3: adding a new task by name + public bool FocusCommand() + { + CPH.TryGetArg("rawInput", out string rawInput); + // make sure there's no separators + var AddOrSelectResponse = FocusOnTask(rawInput); + if (!AddOrSelectResponse.Success) + { + Respond(AddOrSelectResponse.ErrorMsg); + } + else + { + Respond($"Current focused task: {AddOrSelectResponse.Data.Item1}. {AddOrSelectResponse.Data.Item2}"); + } + + return true; + } + + // return true index + private int GetFocusedTask() + { + List userTasks = ListUserTasks(); + var incompleteTasks = userTasks.Where(t => !t.Completed); + if (incompleteTasks.Count() == 1) + { + return userTasks.FindIndex(t => !t.Completed); + } + + return userTasks.FindIndex(t => t.Focused); + } + + // NEXT COMMAND + // get focused task -> mark it as complete -> focus on existing/new task + public bool NextCommand() + { + List userTasks = ListUserTasks(); + int focusedTaskIndex = GetFocusedTask(); + if (focusedTaskIndex == -1) + { + Respond("Can't use !next command, select a task to complete using !done and/or add another task"); + return false; + } + + // Focus on the task + CPH.TryGetArg("rawInput", out string rawInput); + var FocusOnTaskResponse = FocusOnTask(rawInput); + if (!FocusOnTaskResponse.Success) + { + Respond(FocusOnTaskResponse.ErrorMsg); + return false; + } + + int newTaskIndex = FocusOnTaskResponse.Data.Item1; + string taskString = FocusOnTaskResponse.Data.Item2; + // mark it as complete + string completedTaskName = userTasks[focusedTaskIndex].Name; + userTasks[focusedTaskIndex].Completed = true; + userTasks[focusedTaskIndex].Focused = false; + Broadcast(new { mode = "done", index = focusedTaskIndex }); + SaveIntoTasks(userTasks); + Respond($"Completed '{completedTaskName}'! Moving onto '({newTaskIndex + 1}) {taskString}'"); + return true; + } + + // EDIT COMMAND + public bool EditCommand() + { + CPH.TryGetArg("rawInput", out string rawInput); + // parse check input + var EditData = ParseEditInput(rawInput); + if (!EditData.Success) + { + Respond(EditData.ErrorMsg); + } + + int taskIndex = EditData.Data.Item1; + string newTask = EditData.Data.Item2; + var result = EditTask(taskIndex, newTask); + if (!result.Success) + { + Respond(result.ErrorMsg); + } + else + { + Respond($"Task '{result.Data.Item1}' has been edited to '{result.Data.Item2}'"); + } + + // edit task + return true; + } + + private Response<(string, string)> EditTask(int index, string newTask) + { + List userTasks = ListUserTasks(); + // task data doesn't exist + if (userTasks.Count == 0) + { + // Respond("404 Tasks not found"); + return new(false, default, "Error 404: Tasks not found"); + } + + // out of range + if (index > userTasks.Count || index < 0) + { + return new(false, default, "Error: Invalid task number"); + } + + // incomplete task already exists + bool taskAlreadyExists = userTasks.FindIndex(t => t.Name.Equals(newTask, StringComparison.OrdinalIgnoreCase)) > -1; + if (taskAlreadyExists) + { + return new(false, default, "Error: Task already exists"); + } + + // userTasks + string oldName = userTasks[index].Name; + userTasks[index].Name = newTask; + SaveIntoTasks(userTasks); + Broadcast(new { mode = "edit", index = index, task = newTask }); + return new(true, (oldName, newTask), null); + } + + // simple: !edit + // Response<(int, string)> + private Response<(int, string)> ParseEditInput(string rawInput) + { + bool validEditInput = true; + rawInput = rawInput.Trim(); + int focusedTaskIndex = GetFocusedTask(); + bool hasFocusedTask = focusedTaskIndex > -1; + string[] spaceSeparated = rawInput.Split(new[] { ' ' }, 2); + if (spaceSeparated.Length < 2) + { + validEditInput = false; + if (!hasFocusedTask) + { + return new Response<(int, string)>(false, default, "Error: Not enough arguments, try !edit "); + } + } + + string numberString = spaceSeparated[0]; + string newTask = spaceSeparated[1]; + if (!int.TryParse(numberString, out int index)) + { + validEditInput = false; + if (!hasFocusedTask) + { + return new Response<(int, string)>(false, default, "Error: Not a valid argument, try !edit "); + } + } + + if (validEditInput) + { + return new Response<(int, string)>(true, (index - 1, newTask), null); + } + else + { + return new Response<(int, string)>(true, (focusedTaskIndex, rawInput), null); + } + } + + // CHECK COMMAND + private List ListUserTasks(string? userKey = null) + { + List emptyList = new List(); + if (this.taskData == null || this.taskData.Count == 0) + { + return emptyList; // return empty task list + } + + // user's data doesn't exist + string key = userKey ?? GetKey(); + var userData = this.taskData[key]; + if (userData == null) + { + return emptyList; + } + + // 0 tasks + List tasks = userData.tasks; + if (tasks.Count == 0) + { + return emptyList; + } + + return this.taskData[key].tasks; + } + + public bool CheckCommand() + { + CPH.TryGetArg("rawInput", out string rawInput); + rawInput = rawInput.Trim(); + string? key = null; + bool someoneElse = false; + if (!String.IsNullOrWhiteSpace(rawInput)) + { + key = GetKey(rawInput); + if (key == "") + { + Respond("User not found"); + return false; + } + else + { + someoneElse = true; + } + } + + List userTasks = ListUserTasks(key); + // task data doesn't exist + if (userTasks.Count == 0) + { + Respond("404 Tasks not found"); + return false; + } + + // JOIN THE TASKS INTO ONE MESSAGE + // edit however you like + string message = String.Join(" | ", userTasks.Select((t, index) => $"{index + 1}. {(t.Focused ? "(ongoing) " : "")}{t.Name}")); + if (someoneElse) + { + string theirUser = GetUsername(key); + message = $"{theirUser}'s tasks: {message}"; + } + Respond(message); + return true; + } + + public bool ListCommand() + { + CPH.TryGetArg("rawInput", out string rawInput); + rawInput = rawInput.Trim(); + string separator = "; "; + string validSeparators = ";,|"; + if (rawInput.Length == 1 && validSeparators.Contains(rawInput)) + { + separator = $"{rawInput} "; + } + + List userTasks = ListUserTasks(); + string message = String.Join(separator, userTasks.Where((t, index) => !t.Completed).Select(task => task.Name)); + Respond(message); + return true; + } + + // Helper method to get task index by number or name + private int GetTaskIndex(List tasks, string input) + { + if (int.TryParse(input, out int n)) + { + int index = n - 1; // Convert 1-based to 0-based + return (index >= 0 && index < tasks.Count) ? index : -1; + } + + return tasks.FindIndex(t => t.Name.Equals(input, StringComparison.OrdinalIgnoreCase)); + } + + // --- REMOVE TASK --- + private List ParseTasksInput(string input) + { + input = input.Trim(); + var tasksToRemove = new List(); + bool IsSpaceSeparatedInts = Regex.IsMatch(input, @"^\d+(\s+\d+)*$"); + if (IsSpaceSeparatedInts) + { + return input.Split(' ').ToList(); + } + + var splittedInput = input.Split(separators, StringSplitOptions.RemoveEmptyEntries).Select(t => t.Trim()).ToList(); + if (splittedInput.Count == 0) + { + // get focused task + int focusedTaskIndex = GetFocusedTask() + 1; + // return false index + return new List + { + focusedTaskIndex.ToString() + }; + } + else + { + return splittedInput; + } + } + + private string BuildRemoveMessage(List tasksRemoved, List tasksFailedToRemove) + { + // Build response + string removedString = String.Join(", ", tasksRemoved); + if (tasksFailedToRemove.Count == 0) + { + return $"Removed task(s)!"; + } + else + { + string failedString = String.Join(", ", tasksFailedToRemove); + return $"Failed to remove: {failedString}"; + } + } + + // remove user(s) if they don't have tasks + private void Cleanup(bool userOnly = false) + { + if (userOnly) + { + List userTasks = ListUserTasks(); + if (userTasks.Count == 0) + { + string userKey = GetKey(); + this.taskData.Remove(userKey); + } + } + else + { + List KeysToRemove = new List(); + foreach (var item in this.taskData) + { + if (item.Value.tasks.Count == 0) + { + KeysToRemove.Add(item.Key); + } + } + + foreach (string key in KeysToRemove) + { + this.taskData.Remove(key); + } + } + + SaveTasks(); + } + + public bool RemoveCommand() + { + CPH.TryGetArg("rawInput", out string rawInput); + List userTasks = ListUserTasks(); + if (userTasks.Count == 0) + { + Respond("Error 404: Tasks not found"); + return false; + } + + List tasksToBeRemoved = ParseTasksInput(rawInput); + if (tasksToBeRemoved.Count == 0) + { + Respond("Error: Invalid input"); + return false; + } + + List tasksRemoved = new List(); + List tasksFailedToRemove = new List(); + List taskIndices = new List(); + // Gather indices of tasks to be removed + foreach (string task in tasksToBeRemoved) + { + int index = GetTaskIndex(userTasks, task); + if (index > -1 && !taskIndices.Contains(index)) + { + taskIndices.Add(index); + tasksRemoved.Add(userTasks[index].Name); // Track what we're removing + } + else + { + tasksFailedToRemove.Add(task); + } + } + + // Nothing valid to remove + if (taskIndices.Count == 0) + { + string failedString = String.Join(", ", tasksFailedToRemove); + Respond($"Failed to remove task(s): {failedString}"); + return false; + } + + // Remove from highest index to lowest to preserve indices + foreach (int i in taskIndices.OrderByDescending(n => n)) + { + userTasks.RemoveAt(i); + Broadcast(new { mode = "remove", index = i }); + } + + // Save data + SaveIntoTasks(userTasks); + Cleanup(true); + Respond(BuildRemoveMessage(tasksRemoved, tasksFailedToRemove)); + return true; + } + + // admin delete + // !adel @user + public bool AdminDelete() + { + CPH.TryGetArg("rawInput", out string rawInput); + string[] spaceSeparated = rawInput.Split(new[] { ' ' }, 2); + if (spaceSeparated.Count() == 0) + { + return false; + } + + string user = spaceSeparated[0]; + string key = GetKey(user); + if (key == "") + { + Respond("Error: Unable to find user with that username on the task list"); + return false; + } + + // fuck it, just remove all + this.taskData.Remove(key); + SaveTasks(); + Respond("All of the user's tasks have been deleted"); + Broadcast(new { mode = "admindelete", id = key }); + return true; + } + + // done command + private string BuildCompletedMessage(List tasksCompleted, List tasksFailedToComplete) + { + // Build response + // string completedString = String.Join(", ", tasksCompleted); + if (tasksFailedToComplete.Count == 0 && tasksCompleted.Count == 1) + { + return "Task completed!"; + } + else if (tasksFailedToComplete.Count == 0) + { + return "Completed all task(s) specified!"; + } + else + { + string failedString = String.Join(", ", tasksFailedToComplete); + return $"Failed to complete: {failedString}"; + } + } + + public bool DoneCommand() + { + CPH.TryGetArg("rawInput", out string rawInput); + List userTasks = ListUserTasks(); + if (userTasks.Count == 0) + { + Respond("Error 404: Tasks not found"); + return false; + } + + List tasksToBeCompleted = ParseTasksInput(rawInput); + if (tasksToBeCompleted.Count == 0) + { + Respond("Error: Invalid input"); + return false; + } + + List tasksCompleted = new List(); + List tasksFailedToComplete = new List(); + List taskIndices = new List(); + // Gather indices of tasks to be removed + foreach (string task in tasksToBeCompleted) + { + int index = GetTaskIndex(userTasks, task); + if (index > -1 && !taskIndices.Contains(index) && !userTasks[index].Completed) + { + taskIndices.Add(index); + tasksCompleted.Add(userTasks[index].Name); // Track what we're removing + } + else + { + tasksFailedToComplete.Add(task); + } + } + + // Nothing valid to complete + if (taskIndices.Count == 0) + { + string failedString = String.Join(", ", tasksFailedToComplete); + Respond($"Failed to complete task(s): {failedString}"); + return false; + } + + // Mark all as complete + foreach (int i in taskIndices) + { + userTasks[i].Completed = true; + userTasks[i].Focused = false; + Broadcast(new { mode = "done", index = i }); + } + + // Save data + SaveIntoTasks(userTasks); + Respond(BuildCompletedMessage(tasksCompleted, tasksFailedToComplete)); + return true; + } + + // unfocus + private void Unfocus() + { + List tasks = ListUserTasks(); + for (int i = 0; i < tasks.Count; i++) + { + tasks[i].Focused = false; + } + + SaveIntoTasks(tasks); + } + + public bool UnfocusCommand() + { + Unfocus(); + Respond("Task have been unfocused!"); + Broadcast(new { mode = "unfocus" }); + return true; + } + + // undone + public bool UndoneCommand() + { + CPH.TryGetArg("rawInput", out string rawInput); + List userTasks = ListUserTasks(); + if (userTasks.Count == 0) + { + Respond("Error 404: Tasks not found"); + return false; + } + + List tasksToBeCompleted = ParseUndoneInput(rawInput); + if (tasksToBeCompleted.Count == 0) + { + Respond("Error: Invalid input"); + return false; + } + + List tasksCompleted = new List(); + List tasksFailedToComplete = new List(); + List taskIndices = new List(); + // Gather indices of tasks to be removed + foreach (string task in tasksToBeCompleted) + { + int index = GetTaskIndex(userTasks, task); + if (index > -1 && !taskIndices.Contains(index) && userTasks[index].Completed) + { + taskIndices.Add(index); + tasksCompleted.Add(userTasks[index].Name); // Track what we're removing + } + else + { + tasksFailedToComplete.Add(task); + } + } + + // Nothing valid to complete + if (taskIndices.Count == 0) + { + string failedString = String.Join(", ", tasksFailedToComplete); + Respond($"Failed to un-done task(s): {failedString}"); + return false; + } + + // Mark all as complete + foreach (int i in taskIndices) + { + userTasks[i].Completed = false; + userTasks[i].Focused = false; + Broadcast(new { mode = "undone", index = i }); + } + + // Save data + SaveIntoTasks(userTasks); + Respond(BuildUndoneMessage(tasksCompleted, tasksFailedToComplete)); + return true; + } + + private List ParseUndoneInput(string input) + { + input = input.Trim(); + var tasksToRemove = new List(); + bool IsSpaceSeparatedInts = Regex.IsMatch(input, @"^\d+(\s+\d+)*$"); + if (IsSpaceSeparatedInts) + { + return input.Split(' ').ToList(); + } + + var splittedInput = input.Split(separators, StringSplitOptions.RemoveEmptyEntries).Select(t => t.Trim()).ToList(); + if (splittedInput.Count == 0) + { + // get the only completed task + List userTasks = ListUserTasks(); + int soloCompletedTaskIndex = -1; + var completedTasks = userTasks.Where(t => t.Completed); + if (completedTasks.Count() == 1) + { + soloCompletedTaskIndex = userTasks.FindIndex(t => t.Completed) + 1; + return new List + { + soloCompletedTaskIndex.ToString() + }; + } + } + else + { + return splittedInput; + } + + return new() + { + }; + } + + private string BuildUndoneMessage(List tasksCompleted, List tasksFailedToComplete) + { + // Build response + // string completedString = String.Join(", ", tasksCompleted); + if (tasksFailedToComplete.Count == 0 && tasksCompleted.Count == 1) + { + return "Task marked as incomplete!"; + } + else if (tasksFailedToComplete.Count == 0) + { + return "Task(s) marked as incomplete!"; + } + else + { + string failedString = String.Join(", ", tasksFailedToComplete); + return $"Failed to parse: {failedString}"; + } + } + + // CLEAR TASKS + public bool ClearAllCommand() + { + // clear all TASKS only + foreach (var item in this.taskData) + { + this.taskData[item.Key].tasks = new() + { + }; + } + + Cleanup(false); + SaveTasks(); + Broadcast(new { mode = "clearall" }); + Respond("All tasks have been cleared!"); + return true; + } + + public bool ClearMyDoneCommand() + { + // clear all of the user's completed tasks only + string key = GetKey(); + this.taskData[key].tasks = this.taskData[key].tasks.Where(t => !t.Completed).ToList(); + Cleanup(false); + SaveTasks(); + Broadcast(new { mode = "clearmydone" }); + Respond("All of your completed tasks have been cleared!"); + return true; + } + + public bool ClearDoneCommand() + { + // clear all COMPLETED TASKS only + foreach (var item in this.taskData) + { + this.taskData[item.Key].tasks = this.taskData[item.Key].tasks.Where(t => !t.Completed).ToList(); + } + + Cleanup(false); + SaveTasks(); + Broadcast(new { mode = "cleardone" }); + Respond("All completed tasks have been cleared!"); + return true; + } + + public bool ClearNotStreamerCommand() + { + var streamersUsernames = GetStreamerUsernames(); + // clear all TASKS only if not + this.taskData = this.taskData.Where(kvp => streamersUsernames.Any(s => string.Equals(s, kvp.Value.username, StringComparison.OrdinalIgnoreCase))).ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + Cleanup(false); + SaveTasks(); + Broadcast(new { mode = "clearns" }); + Respond("All completed tasks have been cleared!"); + return true; + } +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..fb2aab5 --- /dev/null +++ b/README.md @@ -0,0 +1,90 @@ +# Rython Task Bot v2 + +![C#](https://img.shields.io/badge/c%23-%23239120.svg?style=for-the-badge&logo=csharp&logoColor=white) +![CSS](https://img.shields.io/badge/Streamer.Bot-%234285F4.svg?style=for-the-badge&logoColor=white) +![Twitch](https://img.shields.io/badge/twitch-8957E5?style=for-the-badge&logo=twitch&logoColor=white) +![YouTube](https://img.shields.io/badge/youtube-FF0000?style=for-the-badge&logo=youtube&logoColor=white) + +Task Bot for Co-working streamers on Twitch & YouTube. + +## Why this Task Bot? + +- Browser Source is optional + - You only need Streamer.Bot running to have it working with Twitch chat +- Compatible with Twitch and YouTube; possibly extending to Kick and Trovo +- Task data is accessible via Streamer.Bot global variable +- Commands' permissions are easily configurable: + - Mod (and Streamer) only + - VIP and above + - Subscribers and above + - etc. +- Easy customizable with other Streamer.Bot actions + +## Setup Instructions + +1. Install and setup [Streamer.Bot](https://streamer.bot/) + - [Nutty's Reference Video](https://youtu.be/gfGy1gRH5ik?t=146) + +2. Open Streamer.Bot and click on Import + + > ![Import Button](./images/import-button.png) + +3. Import the string from [RythonTaskBot.sb](./RythonTaskBot.sb) + + - Copy paste the string into the `Import` box + + OR + + - Drag the file into the `Import` box + + > ![Imported](./images/imported.png) + +4. Click on the `Import` button + +5. You may need to go to the Commands tab and enable the commands. + +## Usage + +### Add task: + +- `!task ` + + Aliases: + - `!a` + - `!add` + - `!taskadd` + +### Edit task: + +- `!edit ` + + Aliases: + - `!e` + - `!taskedit` + +### Complete task: + +- `!done ` +- `!done ` + + Aliases: + - `!f` + - `!finish` + - `!complete` + +### Remove task: + +- `!remove ` + +### Admin delete user's tasks: + +- `!adel @username` + + Aliases: + - `!admindelete` + +(Work in progress) + +## Credits + +- [RythonDev](https://twitch.tv/RythonDev) diff --git a/RythonTaskBot.sb b/RythonTaskBot.sb new file mode 100644 index 0000000..8d80957 --- /dev/null +++ b/RythonTaskBot.sb @@ -0,0 +1 @@ +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 \ No newline at end of file diff --git a/images/import-button.png b/images/import-button.png new file mode 100644 index 0000000..b6d5b91 Binary files /dev/null and b/images/import-button.png differ diff --git a/images/imported.png b/images/imported.png new file mode 100644 index 0000000..685dcf9 Binary files /dev/null and b/images/imported.png differ