From ac3380941043e0f0330a51b56a8ea55a91e2c998 Mon Sep 17 00:00:00 2001 From: Bob Loblaw Date: Thu, 2 Mar 2023 00:04:49 +0100 Subject: [PATCH 1/5] KattGpt v0.0.1 --- .editorconfig | 33 ++-- .github/workflows/release.yml | 1 + Kattbot.sln | 10 ++ Kattbot/BotOptions.cs | 41 +++-- Kattbot/CommandModules/AdminModule.cs | 13 +- .../EventNotifications.cs | 15 +- .../KattGptMessageHandler.cs | 96 ++++++++++++ Kattbot/Program.cs | 4 + Kattbot/Services/GuildSettingsService.cs | 86 +++++++---- Kattbot/Services/KattGpt/ChatGptClient.cs | 48 ++++++ Kattbot/Services/KattGpt/ChatGptModels.cs | 145 ++++++++++++++++++ Kattbot/Services/SharedCache.cs | 107 +++++++++---- Kattbot/Workers/BotWorker.cs | 12 +- Kattbot/Workers/DiscordLoggerWorker.cs | 9 +- Kattbot/Workers/EventQueueWorker.cs | 38 +++-- Kattbot/appsettings.Development.json | 3 +- Kattbot/appsettings.json | 12 +- 17 files changed, 555 insertions(+), 118 deletions(-) create mode 100644 Kattbot/NotificationHandlers/KattGptMessageHandler.cs create mode 100644 Kattbot/Services/KattGpt/ChatGptClient.cs create mode 100644 Kattbot/Services/KattGpt/ChatGptModels.cs 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/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..ad25fab --- /dev/null +++ b/Kattbot/NotificationHandlers/KattGptMessageHandler.cs @@ -0,0 +1,96 @@ +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 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.Contains("[meta]", 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 })); + + // Add previous messages from cache + var prevMessages = (_cache.GetCache(KattGptCache.CacheKey) ?? Array.Empty()) + .ToList(); + + messages.AddRange(prevMessages); + + // 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); + + var request = new ChatCompletionCreateRequest() + { + Model = "gpt-3.5-turbo", + Messages = messages.ToArray(), + }; + + var response = await _chatGpt.ChatCompletionCreate(request); + + var responseMessage = response.Choices[0].Message; + + await channel.SendMessageAsync(responseMessage.Content); + + // Cache user message and chat gpt response message + prevMessages.Add(newUserMessage); + prevMessages.Add(responseMessage); + + _cache.SetCache(KattGptCache.CacheKey, prevMessages.ToArray(), TimeSpan.FromMinutes(10)); + } +} diff --git a/Kattbot/Program.cs b/Kattbot/Program.cs index 74299b2..a69b961 100644 --- a/Kattbot/Program.cs +++ b/Kattbot/Program.cs @@ -8,6 +8,7 @@ using Kattbot.Helpers; using Kattbot.Services; using Kattbot.Services.Images; +using Kattbot.Services.KattGpt; using Kattbot.Workers; using MediatR; using Microsoft.EntityFrameworkCore; @@ -31,14 +32,17 @@ 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.AddMediatR(typeof(Program)); services.AddTransient(typeof(IPipelineBehavior<,>), typeof(CommandRequestPipelineBehaviour<,>)); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); AddWorkers(services); diff --git a/Kattbot/Services/GuildSettingsService.cs b/Kattbot/Services/GuildSettingsService.cs index 5b8bab6..fee99fe 100644 --- a/Kattbot/Services/GuildSettingsService.cs +++ b/Kattbot/Services/GuildSettingsService.cs @@ -1,48 +1,68 @@ -using Kattbot.Data; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; +using System; using System.Threading.Tasks; +using Kattbot.Data; -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/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/SharedCache.cs b/Kattbot/Services/SharedCache.cs index d4b14c3..636b4d0 100644 --- a/Kattbot/Services/SharedCache.cs +++ b/Kattbot/Services/SharedCache.cs @@ -1,56 +1,95 @@ -using Microsoft.Extensions.Caching.Memory; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; +using System; using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Memory; -namespace Kattbot.Services +namespace Kattbot.Services; + +#pragma warning disable SA1402 // File may only contain a single type + +public abstract class SimpleMemoryCache { - public class SharedCache + private static readonly object Lock = new object(); + + private readonly MemoryCache _cache; + + public SimpleMemoryCache(int cacheSize) { - private readonly MemoryCache _cache; + _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; - private static readonly object _lock = new object(); + var loadedData = await delegateFunction(); - public SharedCache() + lock (Lock) { - _cache = new MemoryCache(new MemoryCacheOptions() + _cache.Set(key, loadedData, new MemoryCacheEntryOptions() { - SizeLimit = 1024 + AbsoluteExpirationRelativeToNow = duration, + Size = 1, }); + + return loadedData; } + } - public async Task LoadFromCacheAsync(string key, Func> delegateFunction, TimeSpan duration) + public void SetCache(string key, T value, TimeSpan duration) + { + lock (Lock) { - if (_cache.TryGetValue(key, out T value)) - return value; - - var loadedData = await delegateFunction(); - - lock (_lock) + _cache.Set(key, value, new MemoryCacheEntryOptions() { - _cache.Set(key, loadedData, new MemoryCacheEntryOptions() - { - AbsoluteExpirationRelativeToNow = duration, - Size = 1 - }); - - return loadedData; - } + AbsoluteExpirationRelativeToNow = duration, + Size = 1, + }); } + } + + public T? GetCache(string key) + { + if (_cache.TryGetValue(key, out T value)) + return value; - public void FlushCache(string key) + return default; + } + + public void FlushCache(string key) + { + lock (Lock) { - lock(_lock) - { - _cache.Remove(key); - } + _cache.Remove(key); } } +} + +public class SharedCache : SimpleMemoryCache +{ + public static string BotChannel => "BotChannel_%d"; + + public static string KattGptChannel => "KattGptChannel_%d"; + + private const int CacheSize = 1024; + + public SharedCache() + : base(CacheSize) + { + } +} + +public class KattGptCache : SimpleMemoryCache +{ + public static string CacheKey => "Messages"; + + private const int CacheSize = 20; - public static class SharedCacheKeys + public KattGptCache() + : base(CacheSize) { - 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..c77e0bd 100644 --- a/Kattbot/appsettings.json +++ b/Kattbot/appsettings.json @@ -13,6 +13,16 @@ "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 speaking cat that is also a robot.", + "You are an AI chat partner that only speaks in Norwegian. You will never respond any other language than Norwegian.", + "Your main goal is to help learners practice writing and reading Norwegian. Keep sentences short and simple.", + "User messages will be prepended by name of the user enclosed. Example for a user named \"Bob\": \"Bob: Hei, hvordan går det?\"", + "If a user message is written in any other language than Norwegian, you will ignore the contents of that message and politely ask them to write in Norwegian instead." + ] } } From 278feb1891708b6615611c6f53a716346719ac99 Mon Sep 17 00:00:00 2001 From: Bob Loblaw Date: Thu, 2 Mar 2023 21:20:11 +0100 Subject: [PATCH 2/5] Add a simple cache queue for kattgpt messages --- .../KattGptMessageHandler.cs | 25 ++++++---- Kattbot/Program.cs | 1 + Kattbot/Services/Cache/CacheQueue.cs | 50 +++++++++++++++++++ Kattbot/Services/{ => Cache}/SharedCache.cs | 18 ++----- Kattbot/Services/GuildSettingsService.cs | 1 + Kattbot/Services/KattGpt/KattGptCache.cs | 15 ++++++ .../KattGpt/KattGptMessageCacheQueue.cs | 15 ++++++ 7 files changed, 101 insertions(+), 24 deletions(-) create mode 100644 Kattbot/Services/Cache/CacheQueue.cs rename Kattbot/Services/{ => Cache}/SharedCache.cs (88%) create mode 100644 Kattbot/Services/KattGpt/KattGptCache.cs create mode 100644 Kattbot/Services/KattGpt/KattGptMessageCacheQueue.cs diff --git a/Kattbot/NotificationHandlers/KattGptMessageHandler.cs b/Kattbot/NotificationHandlers/KattGptMessageHandler.cs index ad25fab..4c4154f 100644 --- a/Kattbot/NotificationHandlers/KattGptMessageHandler.cs +++ b/Kattbot/NotificationHandlers/KattGptMessageHandler.cs @@ -13,6 +13,9 @@ namespace Kattbot.NotificationHandlers; public class KattGptMessageHandler : INotificationHandler { + private const int CacheDurationMinutes = 60; + private const string ChatGptModel = "gpt-3.5-turbo"; + private readonly GuildSettingsService _guildSettingsService; private readonly ChatGptHttpClient _chatGpt; private readonly KattGptOptions _kattGptOptions; @@ -61,23 +64,25 @@ public async Task Handle(MessageCreatedNotification notification, CancellationTo messages.AddRange(systemPropmts.Select(promptMessage => new ChatCompletionMessage { Role = "system", Content = promptMessage })); - // Add previous messages from cache - var prevMessages = (_cache.GetCache(KattGptCache.CacheKey) ?? Array.Empty()) - .ToList(); + var cacheKey = KattGptCache.MessageCacheKey(channel.Id); - messages.AddRange(prevMessages); + 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}" }; + var newUserMessage = new ChatCompletionMessage { Role = "user", Content = $"{newMessageUser}: {newMessageContent}" }; messages.Add(newUserMessage); + // Make request var request = new ChatCompletionCreateRequest() { - Model = "gpt-3.5-turbo", + Model = ChatGptModel, Messages = messages.ToArray(), }; @@ -85,12 +90,14 @@ public async Task Handle(MessageCreatedNotification notification, CancellationTo var responseMessage = response.Choices[0].Message; + // Send message to Discord channel await channel.SendMessageAsync(responseMessage.Content); // Cache user message and chat gpt response message - prevMessages.Add(newUserMessage); - prevMessages.Add(responseMessage); + messageCache.Enqueue(newUserMessage); + messageCache.Enqueue(responseMessage); - _cache.SetCache(KattGptCache.CacheKey, prevMessages.ToArray(), TimeSpan.FromMinutes(10)); + // Cache message cache + _cache.SetCache(cacheKey, messageCache, TimeSpan.FromMinutes(CacheDurationMinutes)); } } diff --git a/Kattbot/Program.cs b/Kattbot/Program.cs index a69b961..bb39963 100644 --- a/Kattbot/Program.cs +++ b/Kattbot/Program.cs @@ -7,6 +7,7 @@ using Kattbot.EventHandlers; using Kattbot.Helpers; using Kattbot.Services; +using Kattbot.Services.Cache; using Kattbot.Services.Images; using Kattbot.Services.KattGpt; using Kattbot.Workers; 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/SharedCache.cs b/Kattbot/Services/Cache/SharedCache.cs similarity index 88% rename from Kattbot/Services/SharedCache.cs rename to Kattbot/Services/Cache/SharedCache.cs index 636b4d0..205e7b1 100644 --- a/Kattbot/Services/SharedCache.cs +++ b/Kattbot/Services/Cache/SharedCache.cs @@ -2,7 +2,7 @@ using System.Threading.Tasks; using Microsoft.Extensions.Caching.Memory; -namespace Kattbot.Services; +namespace Kattbot.Services.Cache; #pragma warning disable SA1402 // File may only contain a single type @@ -70,26 +70,14 @@ public void FlushCache(string key) public class SharedCache : SimpleMemoryCache { + private const int CacheSize = 1024; + public static string BotChannel => "BotChannel_%d"; public static string KattGptChannel => "KattGptChannel_%d"; - private const int CacheSize = 1024; - public SharedCache() : base(CacheSize) { } } - -public class KattGptCache : SimpleMemoryCache -{ - public static string CacheKey => "Messages"; - - private const int CacheSize = 20; - - public KattGptCache() - : base(CacheSize) - { - } -} diff --git a/Kattbot/Services/GuildSettingsService.cs b/Kattbot/Services/GuildSettingsService.cs index fee99fe..ab0bdac 100644 --- a/Kattbot/Services/GuildSettingsService.cs +++ b/Kattbot/Services/GuildSettingsService.cs @@ -1,6 +1,7 @@ using System; using System.Threading.Tasks; using Kattbot.Data; +using Kattbot.Services.Cache; namespace Kattbot.Services; 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)) + { + } +} From 435492dd478b258aa4da53ae5926b531daa39937 Mon Sep 17 00:00:00 2001 From: Bob Loblaw Date: Thu, 2 Mar 2023 21:35:48 +0100 Subject: [PATCH 3/5] Adjust prompts --- Kattbot/appsettings.json | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/Kattbot/appsettings.json b/Kattbot/appsettings.json index c77e0bd..51d2f95 100644 --- a/Kattbot/appsettings.json +++ b/Kattbot/appsettings.json @@ -18,11 +18,12 @@ }, "KattGpt": { "SystemPrompts": [ - "Your name is Kattbot. You act as speaking cat that is also a robot.", - "You are an AI chat partner that only speaks in Norwegian. You will never respond any other language than Norwegian.", - "Your main goal is to help learners practice writing and reading Norwegian. Keep sentences short and simple.", - "User messages will be prepended by name of the user enclosed. Example for a user named \"Bob\": \"Bob: Hei, hvordan går det?\"", - "If a user message is written in any other language than Norwegian, you will ignore the contents of that message and politely ask them to write in Norwegian instead." + "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?\"" ] } } From f78af93f71b2c4357f0667c69d952d955a0ef842 Mon Sep 17 00:00:00 2001 From: Bob Loblaw Date: Thu, 2 Mar 2023 22:41:20 +0100 Subject: [PATCH 4/5] Use a slightly simple meta message prefix --- Kattbot/NotificationHandlers/KattGptMessageHandler.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Kattbot/NotificationHandlers/KattGptMessageHandler.cs b/Kattbot/NotificationHandlers/KattGptMessageHandler.cs index 4c4154f..bed0f04 100644 --- a/Kattbot/NotificationHandlers/KattGptMessageHandler.cs +++ b/Kattbot/NotificationHandlers/KattGptMessageHandler.cs @@ -15,6 +15,7 @@ public class KattGptMessageHandler : INotificationHandler Date: Tue, 21 Mar 2023 21:52:47 +0100 Subject: [PATCH 5/5] Real Dalle tm --- .../Images/DallePromptCommand.cs | 59 ++++--------------- Kattbot/Program.cs | 1 + Kattbot/Services/Dalle/DalleHttpClient.cs | 48 +++++++++++++++ Kattbot/Services/Dalle/DalleModels.cs | 59 +++++++++++++++++++ Kattbot/Services/Images/ImageService.cs | 5 -- Kattbot/appsettings.json | 4 +- 6 files changed, 123 insertions(+), 53 deletions(-) create mode 100644 Kattbot/Services/Dalle/DalleHttpClient.cs create mode 100644 Kattbot/Services/Dalle/DalleModels.cs 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/Program.cs b/Kattbot/Program.cs index bb39963..280d1d5 100644 --- a/Kattbot/Program.cs +++ b/Kattbot/Program.cs @@ -37,6 +37,7 @@ public static IHostBuilder CreateHostBuilder(string[] args) services.AddHttpClient(); services.AddHttpClient(); + services.AddHttpClient(); services.AddMediatR(typeof(Program)); services.AddTransient(typeof(IPipelineBehavior<,>), typeof(CommandRequestPipelineBehaviour<,>)); 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/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/appsettings.json b/Kattbot/appsettings.json index 51d2f95..0f5f0cc 100644 --- a/Kattbot/appsettings.json +++ b/Kattbot/appsettings.json @@ -22,8 +22,8 @@ "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?\"" + "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?\"." ] } }