Initial commit, added a working Twitch bot with OpenAI TTS integration and VLC support for playing audio files.
Signed-off-by: Nathan <nat@natfan.io>
This commit is contained in:
commit
10a6babce9
25
.dockerignore
Normal file
25
.dockerignore
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
**/.dockerignore
|
||||||
|
**/.env
|
||||||
|
**/.git
|
||||||
|
**/.gitignore
|
||||||
|
**/.project
|
||||||
|
**/.settings
|
||||||
|
**/.toolstarget
|
||||||
|
**/.vs
|
||||||
|
**/.vscode
|
||||||
|
**/.idea
|
||||||
|
**/*.*proj.user
|
||||||
|
**/*.dbmdl
|
||||||
|
**/*.jfm
|
||||||
|
**/azds.yaml
|
||||||
|
**/bin
|
||||||
|
**/charts
|
||||||
|
**/docker-compose*
|
||||||
|
**/Dockerfile*
|
||||||
|
**/node_modules
|
||||||
|
**/npm-debug.log
|
||||||
|
**/obj
|
||||||
|
**/secrets.dev.yaml
|
||||||
|
**/values.dev.yaml
|
||||||
|
LICENSE
|
||||||
|
README.md
|
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
/packages/
|
||||||
|
riderModule.iml
|
||||||
|
/_ReSharper.Caches/
|
13
.idea/.idea.TwitchUtils/.idea/.gitignore
vendored
Normal file
13
.idea/.idea.TwitchUtils/.idea/.gitignore
vendored
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# Rider ignored files
|
||||||
|
/projectSettingsUpdater.xml
|
||||||
|
/modules.xml
|
||||||
|
/contentModel.xml
|
||||||
|
/.idea.TwitchUtils.iml
|
||||||
|
# Editor-based HTTP Client requests
|
||||||
|
/httpRequests/
|
||||||
|
# Datasource local storage ignored files
|
||||||
|
/dataSources/
|
||||||
|
/dataSources.local.xml
|
4
.idea/.idea.TwitchUtils/.idea/encodings.xml
Normal file
4
.idea/.idea.TwitchUtils/.idea/encodings.xml
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="Encoding" addBOMForNewFiles="with BOM under Windows, with no BOM otherwise" />
|
||||||
|
</project>
|
81
TTSMe/Bot.cs
Normal file
81
TTSMe/Bot.cs
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
using TwitchLib.Api;
|
||||||
|
using TwitchLib.Client;
|
||||||
|
using TwitchLib.Client.Events;
|
||||||
|
using TwitchLib.Client.Models;
|
||||||
|
using TwitchLib.Communication.Clients;
|
||||||
|
|
||||||
|
namespace TTSMe;
|
||||||
|
|
||||||
|
public class Bot {
|
||||||
|
public const bool VERBOSE = false;
|
||||||
|
private TwitchClient client { get; }
|
||||||
|
|
||||||
|
private TwitchAPI apiClient { get; }
|
||||||
|
|
||||||
|
private string _channel { get; }
|
||||||
|
|
||||||
|
private static Voices voices => Voices.Instance();
|
||||||
|
private static bool SEND_WHISPERS => false;
|
||||||
|
|
||||||
|
public Bot(string clientId, ConnectionCredentials credentials, bool verbose = false) {
|
||||||
|
_channel = credentials.TwitchUsername;
|
||||||
|
client = new TwitchClient(new WebSocketClient {
|
||||||
|
Options = { MessagesAllowedInPeriod = 750, ThrottlingPeriod = TimeSpan.FromSeconds(90) }
|
||||||
|
});
|
||||||
|
apiClient = new TwitchAPI { Settings = { ClientId = clientId, AccessToken = credentials.TwitchOAuth } };
|
||||||
|
client.Initialize(credentials, credentials.TwitchUsername);
|
||||||
|
|
||||||
|
if (VERBOSE) client.OnLog += Log;
|
||||||
|
client.OnMessageReceived += OnTtsMeMessageReceived;
|
||||||
|
|
||||||
|
AppDomain.CurrentDomain.ProcessExit += OnProcessExit!;
|
||||||
|
|
||||||
|
Connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private void SendMessage(string message, string? username = null) {
|
||||||
|
client.SendMessage(_channel, message);
|
||||||
|
if (!SEND_WHISPERS || username is null) return; // TODO: Change this when I figure out how whispers work...
|
||||||
|
client.SendWhisper(username, message);
|
||||||
|
apiClient.Helix.Whispers.SendWhisperAsync(_channel, username, message, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Connect() {
|
||||||
|
while (client is ({IsInitialized: false})) {
|
||||||
|
Console.Error.WriteLine("client is not yet initialized.");
|
||||||
|
Thread.Sleep(250);
|
||||||
|
}
|
||||||
|
Console.WriteLine($"Connecting to channel '{_channel}'...");
|
||||||
|
client.Connect();
|
||||||
|
try {
|
||||||
|
Console.WriteLine($"Connected to channel '{_channel}'.");
|
||||||
|
} catch (Exception e) {
|
||||||
|
Console.WriteLine($"Failed to connect to channel '{_channel}': {e.Message}");
|
||||||
|
Disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Disconnect() {
|
||||||
|
client.Disconnect();
|
||||||
|
Console.WriteLine($"Disconnected from channel '{_channel}'.");
|
||||||
|
}
|
||||||
|
private void OnProcessExit(object? _, EventArgs e) => Disconnect();
|
||||||
|
private static void Log(object? _, OnLogArgs e) => Console.WriteLine($"{e.DateTime:yyyy-MM-ddTHH:mm:ssZ} - {e.Data}");
|
||||||
|
|
||||||
|
private void OnTtsMeMessageReceived(object? _, OnMessageReceivedArgs e) {
|
||||||
|
ChatMessage chatMessage = e.ChatMessage;
|
||||||
|
string username = chatMessage.Username;
|
||||||
|
string message = chatMessage.GetMessageText();
|
||||||
|
if (!chatMessage.HasStoredVoice()) voices.SetRandomVoice(username);
|
||||||
|
|
||||||
|
string? ttsMeCommandResponse = TtsMeCommand.ProcessVoiceChange(chatMessage);
|
||||||
|
if (ttsMeCommandResponse is not null) SendMessage(ttsMeCommandResponse);
|
||||||
|
|
||||||
|
if (message.StartsWith("!ttsme")) return; // Don't process the command message
|
||||||
|
|
||||||
|
Console.WriteLine($"{username} [{voices.GetVoice(username)!}]: !ttsme {message}");
|
||||||
|
|
||||||
|
Voices.ProcessVoiceMessage(chatMessage).Wait();
|
||||||
|
}
|
||||||
|
}
|
21
TTSMe/Dockerfile
Normal file
21
TTSMe/Dockerfile
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
FROM mcr.microsoft.com/dotnet/runtime:9.0 AS base
|
||||||
|
USER $APP_UID
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
|
||||||
|
ARG BUILD_CONFIGURATION=Release
|
||||||
|
WORKDIR /src
|
||||||
|
COPY ["TTSMe/TTSMe.csproj", "TTSMe/"]
|
||||||
|
RUN dotnet restore "TTSMe/TTSMe.csproj"
|
||||||
|
COPY . .
|
||||||
|
WORKDIR "/src/TTSMe"
|
||||||
|
RUN dotnet build "TTSMe.csproj" -c $BUILD_CONFIGURATION -o /app/build
|
||||||
|
|
||||||
|
FROM build AS publish
|
||||||
|
ARG BUILD_CONFIGURATION=Release
|
||||||
|
RUN dotnet publish "TTSMe.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
|
||||||
|
|
||||||
|
FROM base AS final
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=publish /app/publish .
|
||||||
|
ENTRYPOINT ["dotnet", "TTSMe.dll"]
|
16
TTSMe/ExtensionMethods.cs
Normal file
16
TTSMe/ExtensionMethods.cs
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
using EmojiCSharp;
|
||||||
|
using TwitchLib.Client.Models;
|
||||||
|
|
||||||
|
namespace TTSMe;
|
||||||
|
|
||||||
|
public static class ExtensionMethods {
|
||||||
|
internal static string? GetStoredVoice(this ChatMessage chatMessage) => Voices.Instance().GetVoice(chatMessage.Username);
|
||||||
|
internal static bool HasStoredVoice(this ChatMessage chatMessage) => Voices.Instance().HasVoice(chatMessage.Username);
|
||||||
|
// parse out all emojis into their full names
|
||||||
|
internal static string GetMessageText(this ChatMessage chatMessage) =>
|
||||||
|
EmojiParser.ParseToAliases(chatMessage.Message)
|
||||||
|
.Replace(":", " colon ")
|
||||||
|
.Replace("_", " underscore ");
|
||||||
|
|
||||||
|
internal static TextToSpeechRequest ToTTSRequest(this ChatMessage chatMessage) => new(chatMessage.GetMessageText(), chatMessage.GetStoredVoice()!);
|
||||||
|
}
|
13
TTSMe/Program.cs
Normal file
13
TTSMe/Program.cs
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
EmojiCSharp.EmojiManager.Init();
|
||||||
|
LibVLCSharp.Shared.Core.Initialize();
|
||||||
|
|
||||||
|
_ = new TTSMe.Bot(
|
||||||
|
clientId: Environment.GetEnvironmentVariable("CLIENT_ID")!,
|
||||||
|
credentials: new(
|
||||||
|
twitchUsername: Environment.GetEnvironmentVariable("USER_CHANNEL")!,
|
||||||
|
twitchOAuth: Environment.GetEnvironmentVariable("USER_TOKEN")!
|
||||||
|
),
|
||||||
|
verbose: TTSMe.TtsMeCommand.VERBOSE
|
||||||
|
);
|
||||||
|
Console.WriteLine("Press any key to exit...");
|
||||||
|
Console.ReadKey();
|
57
TTSMe/TTSClient.cs
Normal file
57
TTSMe/TTSClient.cs
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using LibVLCSharp.Shared;
|
||||||
|
using TwitchLib.Client.Models;
|
||||||
|
|
||||||
|
namespace TTSMe;
|
||||||
|
|
||||||
|
public static class TTSClient {
|
||||||
|
private static readonly HttpClient httpClient = new() {
|
||||||
|
DefaultRequestHeaders = {
|
||||||
|
Authorization = new AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("OPENAI_API_KEY")),
|
||||||
|
},
|
||||||
|
BaseAddress = new Uri("https://api.openai.com")
|
||||||
|
};
|
||||||
|
private const string basePath = "C:/data/twitch/tts";
|
||||||
|
|
||||||
|
public static async Task<string> GenerateSpeech(ChatMessage chatMessage) {
|
||||||
|
HttpResponseMessage response = await httpClient.PostAsJsonAsync("v1/audio/speech", chatMessage.ToTTSRequest());
|
||||||
|
byte[] bytes = await response.Content.ReadAsByteArrayAsync();
|
||||||
|
string outputPath = Path.Combine(basePath, $"{Guid.NewGuid()}.mp3");
|
||||||
|
await File.WriteAllBytesAsync(outputPath, bytes);
|
||||||
|
return outputPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task PlaySpeech(string path) {
|
||||||
|
try {
|
||||||
|
await Task.Run(() => {
|
||||||
|
TaskCompletionSource<bool> taskCompletionSource = new();
|
||||||
|
using LibVLC libVLC = new();
|
||||||
|
if (TtsMeCommand.VERBOSE) libVLC.Log += (_, e) => Console.WriteLine($"LibVLC [{e.Level}] {e.Module}:{e.Message}");
|
||||||
|
using Media media = new(libVLC, path);
|
||||||
|
using MediaPlayer player = new(media);
|
||||||
|
|
||||||
|
player.EndReached += OnEndReached;
|
||||||
|
player.Play();
|
||||||
|
player.EndReached += (_, _) => taskCompletionSource.TrySetResult(true);
|
||||||
|
taskCompletionSource.Task.Wait();
|
||||||
|
player.Stop();
|
||||||
|
});
|
||||||
|
} catch (Exception ex) {
|
||||||
|
Console.WriteLine($"An error occurred during playback: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void OnEndReached(object? sender, EventArgs _) {
|
||||||
|
if (sender is MediaPlayer mediaPlayer) mediaPlayer.Stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
internal record TextToSpeechRequest(
|
||||||
|
[property: JsonPropertyName("input")] string Input,
|
||||||
|
[property: JsonPropertyName("voice")] string Voice,
|
||||||
|
[property: JsonPropertyName("model")] string Model = "tts-1",
|
||||||
|
[property: JsonPropertyName("response_format")] string ResponseFormat = "mp3",
|
||||||
|
[property: JsonPropertyName("speed")] double Speed = 1.0
|
||||||
|
);
|
26
TTSMe/TTSMe.csproj
Normal file
26
TTSMe/TTSMe.csproj
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Content Include="..\.dockerignore">
|
||||||
|
<Link>.dockerignore</Link>
|
||||||
|
</Content>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="EmojiCSharp" Version="1.0.1" />
|
||||||
|
<PackageReference Include="LibVLCSharp" Version="3.8.5" />
|
||||||
|
<PackageReference Include="OpenAI" Version="2.0.0-beta.7" />
|
||||||
|
<PackageReference Include="TwitchLib" Version="3.5.3" />
|
||||||
|
<PackageReference Include="TwitchLib.Api" Version="3.10.0-preview-e47ba7f" />
|
||||||
|
<PackageReference Include="VideoLAN.LibVLC.Windows" Version="3.0.20" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
42
TTSMe/TtsMeCommand.cs
Normal file
42
TTSMe/TtsMeCommand.cs
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
using TwitchLib.Client.Models;
|
||||||
|
|
||||||
|
namespace TTSMe;
|
||||||
|
|
||||||
|
public abstract class TtsMeCommand {
|
||||||
|
private const string UsageMessage = "Usage: !ttsme <help|get|set [voice]|randomize|optout>.";
|
||||||
|
public const bool VERBOSE = Bot.VERBOSE || false;
|
||||||
|
private static Voices voices => Voices.Instance();
|
||||||
|
|
||||||
|
private abstract class Messages {
|
||||||
|
public static string VoiceSet(string username) => $"ttsme voice for '{username}' is '{Voices.Instance().GetVoice(username) ?? "none"}'";
|
||||||
|
public static string InvalidVoice(string voice) => $"Invalid voice '{voice}'. Valid voices are: {string.Join(", ", Voices.Instance().ValidVoices)}.";
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public static string? ProcessVoiceChange(ChatMessage chatMessage) {
|
||||||
|
string username = chatMessage.Username;
|
||||||
|
string[] args = chatMessage.Message.Split(' ');
|
||||||
|
if (args[0] != "!ttsme") return null;
|
||||||
|
if (args.Length is 1 or > 3) return UsageMessage;
|
||||||
|
string action = args[1];
|
||||||
|
string voice = args[^1];
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case "help": return UsageMessage;
|
||||||
|
|
||||||
|
case "get" when args.Length == 2: return Messages.VoiceSet(username);
|
||||||
|
case "get" when args.Length == 3 && username == chatMessage.Channel: return Messages.VoiceSet(voice);
|
||||||
|
|
||||||
|
case "randomise" or "randomize": voices.SetRandomVoice(username); return $"ttsme voice for '{username}' has been {action}d and is now '{voices.GetVoice(username)}'.";
|
||||||
|
|
||||||
|
case "set" when args.Length != 3: return Messages.InvalidVoice("NULL");
|
||||||
|
case "set" when !voices.IsValidVoice(voice): return Messages.InvalidVoice(voice);
|
||||||
|
case "set" when voices.IsVoice(username, voice): return $"ttsme voice for '{username}' is already set to '{voice}'.";
|
||||||
|
case "set": voices.SetVoice(username, voice); return $"Changing ttsme for {username} to '{voice}'";
|
||||||
|
|
||||||
|
case "optout": voices.RemoveVoice(username); return $"Opting {username} out of ttsme, their voice will no longer be heard by the masses (and me!)";
|
||||||
|
|
||||||
|
default: return UsageMessage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
27
TTSMe/Voices.cs
Normal file
27
TTSMe/Voices.cs
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
using TwitchLib.Client.Models;
|
||||||
|
|
||||||
|
namespace TTSMe;
|
||||||
|
|
||||||
|
public class Voices {
|
||||||
|
private static Voices? instance;
|
||||||
|
public static Voices Instance() => instance ??= new Voices();
|
||||||
|
|
||||||
|
private Dictionary<string, string> UserVoices { get; } = new();
|
||||||
|
internal readonly List<string> ValidVoices = [ "alloy", "echo", "fable", "onyx", "nova", "shimmer" ];
|
||||||
|
|
||||||
|
public bool HasVoice(string username) => UserVoices.ContainsKey(username);
|
||||||
|
public bool IsValidVoice(string voice) => ValidVoices.Contains(voice);
|
||||||
|
public bool IsVoice(string username, string voice) => HasVoice(username) && UserVoices.GetValueOrDefault(username) == voice;
|
||||||
|
public string? GetVoice(string username) => UserVoices.GetValueOrDefault(username);
|
||||||
|
public void SetVoice(string username, string voice) => UserVoices[username] = voice;
|
||||||
|
public void SetRandomVoice(string username) => UserVoices[username] = ValidVoices[new Random().Next(ValidVoices.Count)];
|
||||||
|
public void RemoveVoice(string username) => UserVoices.Remove(username);
|
||||||
|
|
||||||
|
|
||||||
|
public static async Task ProcessVoiceMessage(ChatMessage chatMessage) {
|
||||||
|
string? voice = chatMessage.GetStoredVoice();
|
||||||
|
if (voice is null) return;
|
||||||
|
string filePath = await TTSClient.GenerateSpeech(chatMessage);
|
||||||
|
await TTSClient.PlaySpeech(filePath);
|
||||||
|
}
|
||||||
|
}
|
21
TwitchUtils.sln
Normal file
21
TwitchUtils.sln
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
|
||||||
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TTSMe", "TTSMe\TTSMe.csproj", "{02DF3D65-BBE1-4888-8A6A-B4051CFAFB8B}"
|
||||||
|
EndProject
|
||||||
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{8B637A1E-14A4-4F70-8440-68F6544B99B5}"
|
||||||
|
ProjectSection(SolutionItems) = preProject
|
||||||
|
docker-compose.yml = docker-compose.yml
|
||||||
|
EndProjectSection
|
||||||
|
EndProject
|
||||||
|
Global
|
||||||
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
Release|Any CPU = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
|
{02DF3D65-BBE1-4888-8A6A-B4051CFAFB8B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{02DF3D65-BBE1-4888-8A6A-B4051CFAFB8B}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{02DF3D65-BBE1-4888-8A6A-B4051CFAFB8B}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{02DF3D65-BBE1-4888-8A6A-B4051CFAFB8B}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
EndGlobal
|
6
docker-compose.yml
Normal file
6
docker-compose.yml
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
services:
|
||||||
|
ttsme:
|
||||||
|
image: ttsme
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: TTSMe/Dockerfile
|
Loading…
Reference in New Issue
Block a user