diff --git a/.editorconfig b/.editorconfig index 649a46d..87cf4c9 100644 --- a/.editorconfig +++ b/.editorconfig @@ -15,21 +15,17 @@ tab_width = 4 end_of_line = crlf insert_final_newline = true -#### .NET Coding Conventions #### - -# Organize usings -dotnet_separate_import_directive_groups = false -dotnet_sort_system_directives_first = true - -#### C# Coding Conventions #### - -# Code-block preferences -csharp_style_namespace_declarations = file_scoped +# Visual Studio +csharp_style_namespace_declarations=file_scoped # Use file scoped namespace by default for new class files ### Naming styles ### # Naming rules +dotnet_naming_rule.private_or_internal_static_field_should_be_pascal_case.severity = warning +dotnet_naming_rule.private_or_internal_static_field_should_be_pascal_case.symbols = private_or_internal_static_field +dotnet_naming_rule.private_or_internal_static_field_should_be_pascal_case.style = pascal_case + dotnet_naming_rule.private_or_internal_field_should_be__fieldname.severity = suggestion dotnet_naming_rule.private_or_internal_field_should_be__fieldname.symbols = private_or_internal_field dotnet_naming_rule.private_or_internal_field_should_be__fieldname.style = _fieldname @@ -38,8 +34,16 @@ dotnet_naming_rule.local_should_be_camelcase.severity = warning dotnet_naming_rule.local_should_be_camelcase.symbols = local dotnet_naming_rule.local_should_be_camelcase.style = camelcase +dotnet_naming_rule.constant_field_should_be_pascal_case.severity = warning +dotnet_naming_rule.constant_field_should_be_pascal_case.symbols = constant_field +dotnet_naming_rule.constant_field_should_be_pascal_case.style = pascal_case + # Symbol specifications +dotnet_naming_symbols.private_or_internal_static_field.applicable_kinds = field +dotnet_naming_symbols.private_or_internal_static_field.applicable_accessibilities = internal, private, private_protected +dotnet_naming_symbols.private_or_internal_static_field.required_modifiers = static + dotnet_naming_symbols.private_or_internal_field.applicable_kinds = field dotnet_naming_symbols.private_or_internal_field.applicable_accessibilities = internal, private, private_protected dotnet_naming_symbols.private_or_internal_field.required_modifiers = @@ -48,6 +52,10 @@ dotnet_naming_symbols.local.applicable_kinds = local dotnet_naming_symbols.local.applicable_accessibilities = local dotnet_naming_symbols.local.required_modifiers = +dotnet_naming_symbols.constant_field.applicable_kinds = field +dotnet_naming_symbols.constant_field.applicable_accessibilities = * +dotnet_naming_symbols.constant_field.required_modifiers = const + # Naming styles dotnet_naming_style._fieldname.required_prefix = _ @@ -60,6 +68,11 @@ dotnet_naming_style.camelcase.required_suffix = dotnet_naming_style.camelcase.word_separator = dotnet_naming_style.camelcase.capitalization = camel_case +dotnet_naming_style.pascal_case.required_prefix = +dotnet_naming_style.pascal_case.required_suffix = +dotnet_naming_style.pascal_case.word_separator = +dotnet_naming_style.pascal_case.capitalization = pascal_case + ### Stylecop rules ### # Default rulesets: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2de885c..273d924 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,6 +23,7 @@ jobs: env: CONNECTION_STRING: ${{secrets.DB_CONNECTION_STRING}} BOT_TOKEN: ${{secrets.BOT_TOKEN}} + OPENAI_API_KEY: ${{secrets.OPENAI_API_KEY}} - name: Setup .NET Core uses: actions/setup-dotnet@v3 diff --git a/Kattbot.sln b/Kattbot.sln index 9316651..1ac1347 100644 --- a/Kattbot.sln +++ b/Kattbot.sln @@ -28,6 +28,13 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Kattbot.Common", "Kattbot.C EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Kattbot.Data.Migrations", "Kattbot.Data.Migrations\Kattbot.Data.Migrations.csproj", "{D26776E6-F360-425C-9281-F4E7B176197E}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "deploy", "deploy", "{727EE4ED-CFEC-4BBC-B67A-3DA7F8EE3F1E}" + ProjectSection(SolutionItems) = preProject + deploy\kattbot-backup-db.sh = deploy\kattbot-backup-db.sh + deploy\kattbot-deploy.sh = deploy\kattbot-deploy.sh + deploy\kattbot.service = deploy\kattbot.service + EndProjectSection +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -58,6 +65,9 @@ Global GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {727EE4ED-CFEC-4BBC-B67A-3DA7F8EE3F1E} = {2D6F1BD9-5D5D-4C85-B254-B773679A5AF9} + EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {49047B12-10BC-4E9D-9DA6-758947DF9CE8} EndGlobalSection diff --git a/Kattbot/BotOptions.cs b/Kattbot/BotOptions.cs index 70ff828..474bbe7 100644 --- a/Kattbot/BotOptions.cs +++ b/Kattbot/BotOptions.cs @@ -1,20 +1,29 @@ using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -namespace Kattbot +namespace Kattbot; + +public record BotOptions +{ + public const string OptionsKey = "Kattbot"; + + public string CommandPrefix { get; set; } = null!; + + public string AlternateCommandPrefix { get; set; } = null!; + + public string ConnectionString { get; set; } = null!; + + public string BotToken { get; set; } = null!; + + public ulong ErrorLogGuildId { get; set; } + + public ulong ErrorLogChannelId { get; set; } + + public string OpenAiApiKey { get; set; } = null!; +} + +public record KattGptOptions { - public class BotOptions - { - public const string OptionsKey = "Kattbot"; - - public string CommandPrefix { get; set; } = null!; - public string AlternateCommandPrefix { get; set; } = null!; - public string ConnectionString { get; set; } = null!; - public string BotToken { get; set; } = null!; - public ulong ErrorLogGuildId { get; set; } - public ulong ErrorLogChannelId { get; set; } - } + public const string OptionsKey = "KattGpt"; + + public string[] SystemPrompts { get; set; } = Array.Empty(); } diff --git a/Kattbot/CommandHandlers/Images/DallePromptCommand.cs b/Kattbot/CommandHandlers/Images/DallePromptCommand.cs index f1b4407..d6bfcc9 100644 --- a/Kattbot/CommandHandlers/Images/DallePromptCommand.cs +++ b/Kattbot/CommandHandlers/Images/DallePromptCommand.cs @@ -1,18 +1,16 @@ using System; -using System.Collections.Generic; using System.Linq; -using System.Net.Http; -using System.Text; using System.Threading; using System.Threading.Tasks; using DSharpPlus.CommandsNext; using DSharpPlus.Entities; using Kattbot.Services.Images; +using Kattbot.Services.KattGpt; using MediatR; -using Newtonsoft.Json; namespace Kattbot.CommandHandlers.Images; +#pragma warning disable SA1402 // File may only contain a single type public class DallePromptCommand : CommandRequest { public string Prompt { get; set; } @@ -26,12 +24,12 @@ public DallePromptCommand(CommandContext ctx, string prompt) public class DallePromptCommandHandler : AsyncRequestHandler { - private readonly IHttpClientFactory _httpClientFactory; + private readonly DalleHttpClient _dalleHttpClient; private readonly ImageService _imageService; - public DallePromptCommandHandler(IHttpClientFactory httpClientFactory, ImageService imageService) + public DallePromptCommandHandler(DalleHttpClient dalleHttpClient, ImageService imageService) { - _httpClientFactory = httpClientFactory; + _dalleHttpClient = dalleHttpClient; _imageService = imageService; } @@ -41,41 +39,25 @@ protected override async Task Handle(DallePromptCommand request, CancellationTok try { - HttpClient client = _httpClientFactory.CreateClient(); + var response = await _dalleHttpClient.CreateImage(new CreateImageRequest { Prompt = request.Prompt }); - string url = "https://backend.craiyon.com/generate"; + if (response.Data == null || !response.Data.Any()) throw new Exception("Empty result"); - var body = new DalleRequest { Prompt = request.Prompt }; + var imageUrl = response.Data.First(); - string json = JsonConvert.SerializeObject(body); - var data = new StringContent(json, Encoding.UTF8, "application/json"); + var image = await _imageService.LoadImage(imageUrl.Url); - HttpResponseMessage response = await client.PostAsync(url, data, cancellationToken); - - response.EnsureSuccessStatusCode(); - - string jsonString = await response.Content.ReadAsStringAsync(cancellationToken); - - DalleResponse? searchResponse = JsonConvert.DeserializeObject(jsonString); - - if (searchResponse?.Images == null) - { - throw new Exception("Couldn't deserialize response"); - } - - ImageStreamResult combinedImage = await _imageService.CombineImages(searchResponse.Images.ToArray()); + var imageStream = await _imageService.GetImageStream(image); string safeFileName = new(request.Prompt.Select(c => char.IsLetterOrDigit(c) ? c : '_').ToArray()); - string fileName = $"{safeFileName}.{combinedImage.FileExtension}"; + string fileName = $"{safeFileName}.{imageStream.FileExtension}"; DiscordEmbedBuilder eb = new DiscordEmbedBuilder() .WithTitle(request.Prompt) - .WithImageUrl($"attachment://{fileName}") - .WithFooter("Generated by craiyon.com") - .WithUrl("https://www.craiyon.com/"); + .WithImageUrl($"attachment://{fileName}"); DiscordMessageBuilder mb = new DiscordMessageBuilder() - .AddFile(fileName, combinedImage.MemoryStream) + .AddFile(fileName, imageStream.MemoryStream) .WithEmbed(eb) .WithContent($"There you go {request.Ctx.Member?.Mention ?? "Unknown user"}"); @@ -90,18 +72,3 @@ protected override async Task Handle(DallePromptCommand request, CancellationTok } } } - -public class DalleResponse -{ - [JsonProperty("images")] - public List? Images; - - [JsonProperty("version")] - public string? Version; -} - -public class DalleRequest -{ - [JsonProperty("prompt")] - public string Prompt { get; set; } = string.Empty; -} diff --git a/Kattbot/CommandModules/AdminModule.cs b/Kattbot/CommandModules/AdminModule.cs index 24d52ff..d8acd2e 100644 --- a/Kattbot/CommandModules/AdminModule.cs +++ b/Kattbot/CommandModules/AdminModule.cs @@ -52,7 +52,7 @@ public async Task AddFriend(CommandContext ctx, DiscordMember user) var hasRole = await _botUserRolesRepo.UserHasRole(userId, friendRole); - if(hasRole) + if (hasRole) { await ctx.RespondAsync("User already has role"); return; @@ -93,5 +93,16 @@ public async Task SetBotChannel(CommandContext ctx, DiscordChannel channel) await ctx.RespondAsync($"Set bot channel to #{channel.Name}"); } + + [Command("set-kattgpt-channel")] + public async Task SetKattGptChannel(CommandContext ctx, DiscordChannel channel) + { + var channelId = channel.Id; + var guildId = channel.GuildId!.Value; + + await _guildSettingsService.SetKattGptChannel(guildId, channelId); + + await ctx.RespondAsync($"Set KattGpt channel to #{channel.Name}"); + } } } diff --git a/Kattbot/NotificationHandlers/EventNotifications.cs b/Kattbot/NotificationHandlers/EventNotifications.cs index 74eb41d..df74e9d 100644 --- a/Kattbot/NotificationHandlers/EventNotifications.cs +++ b/Kattbot/NotificationHandlers/EventNotifications.cs @@ -1,5 +1,18 @@ -using MediatR; +using DSharpPlus.EventArgs; +using MediatR; namespace Kattbot.NotificationHandlers; public abstract record EventNotification(EventContext Ctx) : INotification; + +// TODO clean this up by removing EventContext from base contructor entirely +// or at least move the mapping somewhere else +public record MessageCreatedNotification(MessageCreateEventArgs EventArgs) + : EventNotification(new EventContext() + { + Channel = EventArgs.Channel, + Guild = EventArgs.Guild, + User = EventArgs.Author, + Message = EventArgs.Message, + EventName = "MessageCreated", + }); diff --git a/Kattbot/NotificationHandlers/KattGptMessageHandler.cs b/Kattbot/NotificationHandlers/KattGptMessageHandler.cs new file mode 100644 index 0000000..bed0f04 --- /dev/null +++ b/Kattbot/NotificationHandlers/KattGptMessageHandler.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Kattbot.Helpers; +using Kattbot.Services; +using Kattbot.Services.KattGpt; +using MediatR; +using Microsoft.Extensions.Options; + +namespace Kattbot.NotificationHandlers; + +public class KattGptMessageHandler : INotificationHandler +{ + private const int CacheDurationMinutes = 60; + private const string ChatGptModel = "gpt-3.5-turbo"; + private const string MetaMessagePrefix = "msg"; + + private readonly GuildSettingsService _guildSettingsService; + private readonly ChatGptHttpClient _chatGpt; + private readonly KattGptOptions _kattGptOptions; + private readonly KattGptCache _cache; + + public KattGptMessageHandler( + GuildSettingsService guildSettingsService, + ChatGptHttpClient chatGpt, + IOptions kattGptOptions, + KattGptCache cache) + { + _guildSettingsService = guildSettingsService; + _chatGpt = chatGpt; + _kattGptOptions = kattGptOptions.Value; + _cache = cache; + } + + public async Task Handle(MessageCreatedNotification notification, CancellationToken cancellationToken) + { + var args = notification.EventArgs; + var message = args.Message; + var author = args.Author; + var channel = args.Message.Channel; + + if (author.IsBot || author.IsSystem.GetValueOrDefault()) + { + return; + } + + var kattGptChannelId = await _guildSettingsService.GetKattGptChannelId(args.Message.Channel.Guild.Id); + + if (kattGptChannelId == null || kattGptChannelId != args.Message.Channel.Id) + { + return; + } + + if (message.Content.StartsWith(MetaMessagePrefix, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + var messages = new List(); + + // Add system prompt messages + var systemPropmts = _kattGptOptions.SystemPrompts; + + messages.AddRange(systemPropmts.Select(promptMessage => new ChatCompletionMessage { Role = "system", Content = promptMessage })); + + var cacheKey = KattGptCache.MessageCacheKey(channel.Id); + + var messageCache = _cache.GetCache(cacheKey) ?? new KattGptMessageCacheQueue(); + + // Add previous messages from cache + messages.AddRange(messageCache.GetAll()); + + // Add new message from notification + var newMessageContent = message.Content; + var newMessageUser = author.GetNicknameOrUsername(); + + var newUserMessage = new ChatCompletionMessage { Role = "user", Content = $"{newMessageUser}: {newMessageContent}" }; + + messages.Add(newUserMessage); + + // Make request + var request = new ChatCompletionCreateRequest() + { + Model = ChatGptModel, + Messages = messages.ToArray(), + }; + + var response = await _chatGpt.ChatCompletionCreate(request); + + var responseMessage = response.Choices[0].Message; + + // Send message to Discord channel + await channel.SendMessageAsync(responseMessage.Content); + + // Cache user message and chat gpt response message + messageCache.Enqueue(newUserMessage); + messageCache.Enqueue(responseMessage); + + // Cache message cache + _cache.SetCache(cacheKey, messageCache, TimeSpan.FromMinutes(CacheDurationMinutes)); + } +} diff --git a/Kattbot/Program.cs b/Kattbot/Program.cs index 74299b2..280d1d5 100644 --- a/Kattbot/Program.cs +++ b/Kattbot/Program.cs @@ -7,7 +7,9 @@ using Kattbot.EventHandlers; using Kattbot.Helpers; using Kattbot.Services; +using Kattbot.Services.Cache; using Kattbot.Services.Images; +using Kattbot.Services.KattGpt; using Kattbot.Workers; using MediatR; using Microsoft.EntityFrameworkCore; @@ -31,14 +33,18 @@ public static IHostBuilder CreateHostBuilder(string[] args) .ConfigureServices((hostContext, services) => { services.Configure(hostContext.Configuration.GetSection(BotOptions.OptionsKey)); + services.Configure(hostContext.Configuration.GetSection(KattGptOptions.OptionsKey)); services.AddHttpClient(); + services.AddHttpClient(); + services.AddHttpClient(); services.AddMediatR(typeof(Program)); services.AddTransient(typeof(IPipelineBehavior<,>), typeof(CommandRequestPipelineBehaviour<,>)); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); AddWorkers(services); diff --git a/Kattbot/Services/Cache/CacheQueue.cs b/Kattbot/Services/Cache/CacheQueue.cs new file mode 100644 index 0000000..946cc73 --- /dev/null +++ b/Kattbot/Services/Cache/CacheQueue.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; + +namespace Kattbot.Services.Cache; +public abstract class CacheQueue +{ + private readonly int _maxSize; + private readonly TimeSpan _maxAge; + + private readonly ConcurrentQueue<(T Item, DateTime Expiration)> _queue; + + public CacheQueue(int maxSize, TimeSpan maxAge) + { + _maxSize = maxSize; + _maxAge = maxAge; + + _queue = new ConcurrentQueue<(T, DateTime)>(); + } + + public void Enqueue(T item) + { + RemoveExpiredItems(); + + if (_queue.Count >= _maxSize) + { + _ = _queue.TryDequeue(out _); + } + + _queue.Enqueue((item, DateTime.UtcNow.Add(_maxAge))); + } + + public IEnumerable GetAll() + { + RemoveExpiredItems(); + + return _queue.ToList().Select(l => l.Item); + } + + private void RemoveExpiredItems() + { + if (_queue.IsEmpty) return; + + while (_queue.TryPeek(out var peakItem) && peakItem.Expiration < DateTime.UtcNow) + { + _ = _queue.TryDequeue(out _); + } + } +} diff --git a/Kattbot/Services/Cache/SharedCache.cs b/Kattbot/Services/Cache/SharedCache.cs new file mode 100644 index 0000000..205e7b1 --- /dev/null +++ b/Kattbot/Services/Cache/SharedCache.cs @@ -0,0 +1,83 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Memory; + +namespace Kattbot.Services.Cache; + +#pragma warning disable SA1402 // File may only contain a single type + +public abstract class SimpleMemoryCache +{ + private static readonly object Lock = new object(); + + private readonly MemoryCache _cache; + + public SimpleMemoryCache(int cacheSize) + { + _cache = new MemoryCache(new MemoryCacheOptions() + { + SizeLimit = cacheSize, + }); + } + + public async Task LoadFromCacheAsync(string key, Func> delegateFunction, TimeSpan duration) + { + if (_cache.TryGetValue(key, out T value)) + return value; + + var loadedData = await delegateFunction(); + + lock (Lock) + { + _cache.Set(key, loadedData, new MemoryCacheEntryOptions() + { + AbsoluteExpirationRelativeToNow = duration, + Size = 1, + }); + + return loadedData; + } + } + + public void SetCache(string key, T value, TimeSpan duration) + { + lock (Lock) + { + _cache.Set(key, value, new MemoryCacheEntryOptions() + { + AbsoluteExpirationRelativeToNow = duration, + Size = 1, + }); + } + } + + public T? GetCache(string key) + { + if (_cache.TryGetValue(key, out T value)) + return value; + + return default; + } + + public void FlushCache(string key) + { + lock (Lock) + { + _cache.Remove(key); + } + } +} + +public class SharedCache : SimpleMemoryCache +{ + private const int CacheSize = 1024; + + public static string BotChannel => "BotChannel_%d"; + + public static string KattGptChannel => "KattGptChannel_%d"; + + public SharedCache() + : base(CacheSize) + { + } +} diff --git a/Kattbot/Services/Dalle/DalleHttpClient.cs b/Kattbot/Services/Dalle/DalleHttpClient.cs new file mode 100644 index 0000000..138b488 --- /dev/null +++ b/Kattbot/Services/Dalle/DalleHttpClient.cs @@ -0,0 +1,48 @@ +using System; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +using Microsoft.Extensions.Options; + +namespace Kattbot.Services.KattGpt; + +public class DalleHttpClient +{ + private readonly HttpClient _client; + + public DalleHttpClient(HttpClient client, IOptions options) + { + _client = client; + + _client.BaseAddress = new Uri("https://api.openai.com/v1/images/"); + _client.DefaultRequestHeaders.Add("Accept", "application/json"); + _client.DefaultRequestHeaders.Add("Authorization", $"Bearer {options.Value.OpenAiApiKey}"); + } + + public async Task CreateImage(CreateImageRequest request) + { + JsonSerializerOptions opts = new JsonSerializerOptions() { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault }; + + var response = await _client.PostAsJsonAsync("generations", request, opts); + + try + { + response.EnsureSuccessStatusCode(); + + var jsonStream = await response.Content.ReadAsStreamAsync(); + + var parsedResponse = (await JsonSerializer.DeserializeAsync(jsonStream)) + ?? throw new Exception("Failed to parse response"); + + return parsedResponse; + } + catch (Exception) + { + var errorMessage = await response.Content.ReadAsStringAsync(); + + throw new Exception($"HTTP {response.StatusCode}: {errorMessage}"); + } + } +} diff --git a/Kattbot/Services/Dalle/DalleModels.cs b/Kattbot/Services/Dalle/DalleModels.cs new file mode 100644 index 0000000..26a9e0d --- /dev/null +++ b/Kattbot/Services/Dalle/DalleModels.cs @@ -0,0 +1,59 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Kattbot.Services.KattGpt; + +public record CreateImageRequest +{ + /// + /// Gets or sets a text description of the desired image(s). The maximum length is 1000 characters. + /// https://platform.openai.com/docs/api-reference/images/create#images/create-prompt. + /// + [JsonPropertyName("prompt")] + public string Prompt { get; set; } = null!; + + /// + /// Gets or sets the number of images to generate. Must be between 1 and 10. + /// Defaults to 1. + /// https://platform.openai.com/docs/api-reference/images/create#images/create-n. + /// + [JsonPropertyName("n")] + public int? N { get; set; } + + /// + /// Gets or sets the size of the generated images. Must be one of 256x256, 512x512, or 1024x1024. + /// Defaults to 1024x1024 + /// https://platform.openai.com/docs/api-reference/images/create#images/create-size. + /// + [JsonPropertyName("size")] + public string? Size { get; set; } = null!; + + /// + /// Gets or sets the format in which the generated images are returned. Must be one of url or b64_json. + /// Defaults to url + /// https://platform.openai.com/docs/api-reference/images/create#images/create-response_format. + /// + [JsonPropertyName("response_format")] + public string? ResponseFormat { get; set; } = null!; + + /// + /// Gets or sets a unique identifier representing your end-user, which can help OpenAI to monitor and detect abuse. + /// https://platform.openai.com/docs/api-reference/chat/create#chat/create-user. + /// + public string? User { get; set; } +} + +public record CreateImageResponse +{ + [JsonPropertyName("created")] + public long Created { get; set; } + + [JsonPropertyName("data")] + public IEnumerable Data { get; set; } = null!; +} + +public record ImageResponseUrlData +{ + [JsonPropertyName("url")] + public string Url { get; set; } = null!; +} diff --git a/Kattbot/Services/GuildSettingsService.cs b/Kattbot/Services/GuildSettingsService.cs index 5b8bab6..ab0bdac 100644 --- a/Kattbot/Services/GuildSettingsService.cs +++ b/Kattbot/Services/GuildSettingsService.cs @@ -1,48 +1,69 @@ -using Kattbot.Data; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; +using System; using System.Threading.Tasks; +using Kattbot.Data; +using Kattbot.Services.Cache; -namespace Kattbot.Services +namespace Kattbot.Services; + +public class GuildSettingsService { - public class GuildSettingsService + private static readonly string BotChannel = "BotChannel"; + private static readonly string KattGptChannel = "KattGptChannel"; + + private readonly GuildSettingsRepository _guildSettingsRepo; + private readonly SharedCache _cache; + + public GuildSettingsService( + GuildSettingsRepository guildSettingsRepo, + SharedCache cache) + { + _guildSettingsRepo = guildSettingsRepo; + _cache = cache; + } + + public async Task SetBotChannel(ulong guildId, ulong channelId) + { + await _guildSettingsRepo.SaveGuildSetting(guildId, BotChannel, channelId.ToString()); + + var cacheKey = string.Format(SharedCache.BotChannel, guildId); + + _cache.FlushCache(cacheKey); + } + + public async Task GetBotChannelId(ulong guildId) { - private readonly GuildSettingsRepository _guildSettingsRepo; - private readonly SharedCache _cache; + var cacheKey = string.Format(SharedCache.BotChannel, guildId); + + var channelId = await _cache.LoadFromCacheAsync( + cacheKey, + async () => await _guildSettingsRepo.GetGuildSetting(guildId, BotChannel), + TimeSpan.FromMinutes(10)); - private static readonly string BotChannel = "BotChannel"; + var parsed = ulong.TryParse(channelId, out var result); - public GuildSettingsService( - GuildSettingsRepository guildSettingsRepo, - SharedCache cache - ) - { - _guildSettingsRepo = guildSettingsRepo; - _cache = cache; - } + return parsed ? result : null; + } - public async Task SetBotChannel(ulong guildId, ulong channelId) - { - await _guildSettingsRepo.SaveGuildSetting(guildId, BotChannel, channelId.ToString()); + public async Task SetKattGptChannel(ulong guildId, ulong channelId) + { + await _guildSettingsRepo.SaveGuildSetting(guildId, KattGptChannel, channelId.ToString()); - var cacheKey = string.Format(SharedCacheKeys.BotChannel, guildId); + var cacheKey = string.Format(SharedCache.KattGptChannel, guildId); - _cache.FlushCache(cacheKey); - } + _cache.FlushCache(cacheKey); + } - public async Task GetBotChannelId(ulong guildId) - { - var cacheKey = string.Format(SharedCacheKeys.BotChannel, guildId); + public async Task GetKattGptChannelId(ulong guildId) + { + var cacheKey = string.Format(SharedCache.KattGptChannel, guildId); - var channelId = await _cache.LoadFromCacheAsync(cacheKey, async () => - await _guildSettingsRepo.GetGuildSetting(guildId, BotChannel), - TimeSpan.FromMinutes(10)); + var channelId = await _cache.LoadFromCacheAsync( + cacheKey, + async () => await _guildSettingsRepo.GetGuildSetting(guildId, KattGptChannel), + TimeSpan.FromMinutes(10)); - var parsed = ulong.TryParse(channelId, out var result); + var parsed = ulong.TryParse(channelId, out var result); - return parsed ? (ulong?)result : null; - } + return parsed ? result : null; } } diff --git a/Kattbot/Services/Images/ImageService.cs b/Kattbot/Services/Images/ImageService.cs index 9b297da..fc1c7cb 100644 --- a/Kattbot/Services/Images/ImageService.cs +++ b/Kattbot/Services/Images/ImageService.cs @@ -115,10 +115,6 @@ public ImageResult CropImageToCircle(ImageResult imageResult) var ellipsePath = new EllipsePolygon(image.Width / 2, image.Height / 2, image.Width, image.Height); - //var squarePath = new RectangularPolygon(0, 0, image.Width, image.Height); - - //var clippedSquare = squarePath.Clip(ellipsePath); - var cloned = image.Clone(i => { i.SetGraphicsOptions(new GraphicsOptions() @@ -127,7 +123,6 @@ public ImageResult CropImageToCircle(ImageResult imageResult) AlphaCompositionMode = PixelAlphaCompositionMode.DestIn, }); - //i.Fill(Color.Red, clippedSquare); i.Fill(Color.Red, ellipsePath); }); diff --git a/Kattbot/Services/KattGpt/ChatGptClient.cs b/Kattbot/Services/KattGpt/ChatGptClient.cs new file mode 100644 index 0000000..b1179c7 --- /dev/null +++ b/Kattbot/Services/KattGpt/ChatGptClient.cs @@ -0,0 +1,48 @@ +using System; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +using Microsoft.Extensions.Options; + +namespace Kattbot.Services.KattGpt; + +public class ChatGptHttpClient +{ + private readonly HttpClient _client; + + public ChatGptHttpClient(HttpClient client, IOptions options) + { + _client = client; + + _client.BaseAddress = new Uri("https://api.openai.com/v1/chat/"); + _client.DefaultRequestHeaders.Add("Accept", "application/json"); + _client.DefaultRequestHeaders.Add("Authorization", $"Bearer {options.Value.OpenAiApiKey}"); + } + + public async Task ChatCompletionCreate(ChatCompletionCreateRequest request) + { + JsonSerializerOptions opts = new JsonSerializerOptions() { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault }; + + var response = await _client.PostAsJsonAsync("completions", request, opts); + + try + { + response.EnsureSuccessStatusCode(); + + var jsonStream = await response.Content.ReadAsStreamAsync(); + + var parsedResponse = (await JsonSerializer.DeserializeAsync(jsonStream)) + ?? throw new Exception("Failed to parse response"); + + return parsedResponse; + } + catch (Exception) + { + var errorMessage = await response.Content.ReadAsStringAsync(); + + throw new Exception($"HTTP {response.StatusCode}: {errorMessage}"); + } + } +} diff --git a/Kattbot/Services/KattGpt/ChatGptModels.cs b/Kattbot/Services/KattGpt/ChatGptModels.cs new file mode 100644 index 0000000..fb4fc8a --- /dev/null +++ b/Kattbot/Services/KattGpt/ChatGptModels.cs @@ -0,0 +1,145 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Kattbot.Services.KattGpt; + +public record ChatCompletionCreateRequest +{ + /// + /// Gets or sets iD of the model to use. Currently, only gpt-3.5-turbo and gpt-3.5-turbo-0301 are supported. + /// https://platform.openai.com/docs/api-reference/chat/create#chat/create-model. + /// + [JsonPropertyName("model")] + public string Model { get; set; } = null!; + + /// + /// Gets or sets the messages to generate chat completions for, in the chat format. + /// https://platform.openai.com/docs/api-reference/chat/create#chat/create-messages. + /// + [JsonPropertyName("messages")] + public ChatCompletionMessage[] Messages { get; set; } = null!; + + /// + /// Gets or sets what sampling temperature to use, between 0 and 2. Higher values like 0.8 will make the output more random, while lower values like 0.2 will make it more focused and deterministic. + /// We generally recommend altering this or top_p but not both. + /// Defaults to 1. + /// https://platform.openai.com/docs/api-reference/chat/create#chat/create-temperature. + /// + [JsonPropertyName("temperature")] + public float? Temperature { get; set; } + + /// + /// Gets or sets an alternative to sampling with temperature, called nucleus sampling, where the model considers the results of the + /// tokens with top_p probability mass. So 0.1 means only the tokens comprising the top 10% probability mass are + /// considered. + /// We generally recommend altering this or temperature but not both. + /// Defaults to 1. + /// https://platform.openai.com/docs/api-reference/chat/create#chat/create-top_p. + /// + [JsonPropertyName("top_p")] + public float? TopP { get; set; } + + /// + /// Gets or sets how many chat completion choices to generate for each input message. + /// Defaults to 1. + /// https://platform.openai.com/docs/api-reference/chat/create#chat/create-n. + /// + [JsonPropertyName("n")] + public int? N { get; set; } + + /// + /// Gets or sets up to 4 sequences where the API will stop generating further tokens. The returned text will not contain the stop + /// sequence. Defaults to null. + /// https://platform.openai.com/docs/api-reference/chat/create#chat/create-stop. + /// + [JsonPropertyName("stop")] + public string? Stop { get; set; } + + /// + /// Gets or sets if set, partial message deltas will be sent, like in ChatGPT. Tokens will be sent as data-only server-sent events as they become available, + /// with the stream terminated by a data: [DONE] message. + /// Defaults to false. + /// https://platform.openai.com/docs/api-reference/chat/create#chat/create-stream. + /// + [JsonPropertyName("stream")] + public bool? Stream { get; set; } + + /// + /// Gets or sets number between -2.0 and 2.0. Positive values penalize new tokens based on their existing frequency in the text so far, + /// decreasing the model's likelihood to repeat the same line verbatim. Defaults to 0 + /// https://platform.openai.com/docs/api-reference/chat/create#chat/create-frequency_penalty. + /// + [JsonPropertyName("frequency_penalty")] + public float? FrequencyPenalty { get; set; } + + /// + /// Gets or sets number between -2.0 and 2.0. Positive values penalize new tokens based on whether they appear in the text so far, + /// increasing the model's likelihood to talk about new topics. Defaults to 0 + /// https://platform.openai.com/docs/api-reference/chat/create#chat/create-presence_penalty. + /// + [JsonPropertyName("presence_penalty")] + public float? PresencePenalty { get; set; } + + /// + /// Gets or sets a unique identifier representing your end-user, which can help OpenAI to monitor and detect abuse. + /// https://platform.openai.com/docs/api-reference/chat/create#chat/create-user. + /// + public string? User { get; set; } +} + +public record ChatCompletionMessage +{ + /// + /// Gets or sets can be either “system”, “user”, or “assistant”. + /// + [JsonPropertyName("role")] + public string Role { get; set; } = null!; + + /// + /// Gets or sets the content of the message. + /// + [JsonPropertyName("content")] + public string Content { get; set; } = null!; +} + +public record ChatCompletionCreateResponse +{ + [JsonPropertyName("id")] + public string Id { get; set; } = null!; + + [JsonPropertyName("object")] + public string Object { get; set; } = null!; + + [JsonPropertyName("created")] + public int Created { get; set; } + + [JsonPropertyName("choices")] + public List Choices { get; set; } = new List(); + + [JsonPropertyName("usage")] + public Usage Usage { get; set; } = null!; +} + +public record Usage +{ + [JsonPropertyName("prompt_tokens")] + public int PromptTokens { get; set; } + + [JsonPropertyName("completion_tokens")] + public int CompletionTokens { get; set; } + + [JsonPropertyName("total_tokens")] + public int TotalTokens { get; set; } +} + +public record Choice +{ + [JsonPropertyName("index")] + public int Index { get; set; } + + [JsonPropertyName("message")] + public ChatCompletionMessage Message { get; set; } = null!; + + [JsonPropertyName("finish_reason")] + public string FinishReason { get; set; } = null!; +} diff --git a/Kattbot/Services/KattGpt/KattGptCache.cs b/Kattbot/Services/KattGpt/KattGptCache.cs new file mode 100644 index 0000000..e07c63a --- /dev/null +++ b/Kattbot/Services/KattGpt/KattGptCache.cs @@ -0,0 +1,15 @@ +using Kattbot.Services.Cache; + +namespace Kattbot.Services.KattGpt; + +public class KattGptCache : SimpleMemoryCache +{ + public static string MessageCacheKey(ulong channelId) => $"Message_{channelId}"; + + private const int CacheSize = 32; + + public KattGptCache() + : base(CacheSize) + { + } +} diff --git a/Kattbot/Services/KattGpt/KattGptMessageCacheQueue.cs b/Kattbot/Services/KattGpt/KattGptMessageCacheQueue.cs new file mode 100644 index 0000000..72e2d11 --- /dev/null +++ b/Kattbot/Services/KattGpt/KattGptMessageCacheQueue.cs @@ -0,0 +1,15 @@ +using System; +using Kattbot.Services.Cache; + +namespace Kattbot.Services.KattGpt; + +public class KattGptMessageCacheQueue : CacheQueue +{ + private const int MaxSize = 32; + private const int MaxAgeMinutes = 5; + + public KattGptMessageCacheQueue() + : base(MaxSize, TimeSpan.FromMinutes(MaxAgeMinutes)) + { + } +} diff --git a/Kattbot/Services/SharedCache.cs b/Kattbot/Services/SharedCache.cs deleted file mode 100644 index d4b14c3..0000000 --- a/Kattbot/Services/SharedCache.cs +++ /dev/null @@ -1,56 +0,0 @@ -using Microsoft.Extensions.Caching.Memory; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace Kattbot.Services -{ - public class SharedCache - { - private readonly MemoryCache _cache; - - private static readonly object _lock = new object(); - - public SharedCache() - { - _cache = new MemoryCache(new MemoryCacheOptions() - { - SizeLimit = 1024 - }); - } - - public async Task LoadFromCacheAsync(string key, Func> delegateFunction, TimeSpan duration) - { - if (_cache.TryGetValue(key, out T value)) - return value; - - var loadedData = await delegateFunction(); - - lock (_lock) - { - _cache.Set(key, loadedData, new MemoryCacheEntryOptions() - { - AbsoluteExpirationRelativeToNow = duration, - Size = 1 - }); - - return loadedData; - } - } - - public void FlushCache(string key) - { - lock(_lock) - { - _cache.Remove(key); - } - } - } - - public static class SharedCacheKeys - { - public static string BotChannel => "BotChannel_%d"; - } -} diff --git a/Kattbot/Workers/BotWorker.cs b/Kattbot/Workers/BotWorker.cs index ed031ad..b4fe80d 100644 --- a/Kattbot/Workers/BotWorker.cs +++ b/Kattbot/Workers/BotWorker.cs @@ -8,6 +8,7 @@ using DSharpPlus.EventArgs; using Kattbot.CommandModules.TypeReaders; using Kattbot.EventHandlers; +using Kattbot.NotificationHandlers; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -22,6 +23,7 @@ public class BotWorker : IHostedService private readonly IServiceProvider _serviceProvider; private readonly CommandEventHandler _commandEventHandler; private readonly EmoteEventHandler _emoteEventHandler; + private readonly EventQueueChannel _eventQueue; public BotWorker( IOptions options, @@ -29,7 +31,8 @@ public BotWorker( DiscordClient client, IServiceProvider serviceProvider, CommandEventHandler commandEventHandler, - EmoteEventHandler emoteEventHandler) + EmoteEventHandler emoteEventHandler, + EventQueueChannel eventQueue) { _options = options.Value; _logger = logger; @@ -37,6 +40,7 @@ public BotWorker( _serviceProvider = serviceProvider; _commandEventHandler = commandEventHandler; _emoteEventHandler = emoteEventHandler; + _eventQueue = eventQueue; } public async Task StartAsync(CancellationToken cancellationToken) @@ -53,8 +57,6 @@ public async Task StartAsync(CancellationToken cancellationToken) EnableDefaultHelp = false, }); - await _client.ConnectAsync(); - commands.RegisterConverter(new GenericArgumentConverter()); commands.RegisterCommands(Assembly.GetExecutingAssembly()); @@ -64,6 +66,10 @@ public async Task StartAsync(CancellationToken cancellationToken) _commandEventHandler.RegisterHandlers(commands); _emoteEventHandler.RegisterHandlers(); + + _client.MessageCreated += (sender, args) => _eventQueue.Writer.WriteAsync(new MessageCreatedNotification(args)).AsTask(); + + await _client.ConnectAsync(); } private Task OnClientDisconnected(DiscordClient sender, SocketCloseEventArgs e) diff --git a/Kattbot/Workers/DiscordLoggerWorker.cs b/Kattbot/Workers/DiscordLoggerWorker.cs index f73f9a5..36276ca 100644 --- a/Kattbot/Workers/DiscordLoggerWorker.cs +++ b/Kattbot/Workers/DiscordLoggerWorker.cs @@ -36,7 +36,14 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) if (logChannel != null) { - await logChannel.SendMessageAsync(logItem.Message); + try + { + await logChannel.SendMessageAsync(logItem.Message); + } + catch (Exception ex) + { + _logger.LogError(ex, "{Error}", ex.Message); + } } _logger.LogDebug("Dequeued (parallel) command. {RemainingMessageCount} left in queue", _channel.Reader.Count); diff --git a/Kattbot/Workers/EventQueueWorker.cs b/Kattbot/Workers/EventQueueWorker.cs index 9ca7b76..9c01f35 100644 --- a/Kattbot/Workers/EventQueueWorker.cs +++ b/Kattbot/Workers/EventQueueWorker.cs @@ -26,41 +26,45 @@ public EventQueueWorker(ILogger logger, EventQueueChannel chan protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - INotification? @event = null; - try { await foreach (INotification notification in _channel.Reader.ReadAllAsync(stoppingToken)) { - @event = notification; + INotification @event = notification; - if (@event != null) + if (@event == null) { - _logger.LogDebug("Dequeued event. {RemainingMessageCount} left in queue", _channel.Reader.Count); + continue; + } + _logger.LogDebug("Dequeued event. {RemainingMessageCount} left in queue", _channel.Reader.Count); + + try + { await _publisher.Publish(@event, stoppingToken); } + catch (AggregateException ex) + { + foreach (Exception innerEx in ex.InnerExceptions) + { + if (@event is not null and EventNotification eventNotification) + { + _discordErrorLogger.LogDiscordError(eventNotification.Ctx, innerEx.Message); + } + + _logger.LogError(innerEx, nameof(EventQueueWorker)); + } + } } } catch (TaskCanceledException) { _logger.LogDebug("{Worker} execution is being cancelled", nameof(EventQueueWorker)); } - catch (AggregateException ex) - { - foreach (Exception innerEx in ex.InnerExceptions) - { - if (@event is not null and EventNotification notification) - { - _discordErrorLogger.LogDiscordError(notification.Ctx, innerEx.Message); - } - - _logger.LogError(innerEx, nameof(EventQueueWorker)); - } - } catch (Exception ex) { _logger.LogError(ex, nameof(EventQueueWorker)); + _discordErrorLogger.LogDiscordError(ex.Message); } } } diff --git a/Kattbot/appsettings.Development.json b/Kattbot/appsettings.Development.json index e2c76e2..9ca39d7 100644 --- a/Kattbot/appsettings.Development.json +++ b/Kattbot/appsettings.Development.json @@ -13,6 +13,7 @@ "ConnectionString": "__CONNECTION_STRING__", "BotToken": "__BOT_TOKEN__", "ErrorLogGuildId": "753161640496857149", - "ErrorLogChannelId": "821763830577102848" + "ErrorLogChannelId": "821763830577102848", + "OpenAiApiKey": "__OPENAI_API_KEY__" } } diff --git a/Kattbot/appsettings.json b/Kattbot/appsettings.json index 906fad1..0f5f0cc 100644 --- a/Kattbot/appsettings.json +++ b/Kattbot/appsettings.json @@ -13,6 +13,17 @@ "ConnectionString": "__CONNECTION_STRING__", "BotToken": "__BOT_TOKEN__", "ErrorLogGuildId": "753161640496857149", - "ErrorLogChannelId": "821763787845402715" + "ErrorLogChannelId": "821763787845402715", + "OpenAiApiKey": "__OPENAI_API_KEY__" + }, + "KattGpt": { + "SystemPrompts": [ + "Your name is Kattbot. You act as talking cat that is also a robot. Your favorite color is indigo.", + "You are a member of a Discord server called NELLE. You communicate with multiple users in the same chat channel.", + "Your main goal is to help learners practice writing and reading Norwegian by discussing various topics. Keep sentences short and simple.", + "You understand many different languages, but you only respond in Norwegian. You strongly prefer that other users write in Norwegian as well.", + "Messages from other users will be prefixed by the name of the user. Example for a user named \"Bob\": \"Bob: Hei, hvordan går det?\".", + "Your response only includes the message without a name prefix. Example: \"Hei, Bob. Jeg har det bra. Hva med deg?\"." + ] } }