diff --git a/.editorconfig b/.editorconfig index 2b1efbd..82610b6 100644 --- a/.editorconfig +++ b/.editorconfig @@ -28,6 +28,7 @@ dotnet_diagnostic.CA1848.severity = none dotnet_diagnostic.IDE0100.severity = none dotnet_diagnostic.IDE0058.severity = none dotnet_diagnostic.IDE0290.severity = none +dotnet_diagnostic.CA1031.severity = none # Organize usings dotnet_separate_import_directive_groups = true diff --git a/src/src/StevesBot.Library/Discord/Common/DiscordMessage.cs b/src/src/StevesBot.Library/Discord/Common/DiscordMessage.cs index 5cc9a2f..295d586 100644 --- a/src/src/StevesBot.Library/Discord/Common/DiscordMessage.cs +++ b/src/src/StevesBot.Library/Discord/Common/DiscordMessage.cs @@ -19,6 +19,9 @@ public sealed record DiscordMessage [JsonPropertyName("author")] public DiscordUser Author { get; init; } = new DiscordUser(); + [JsonPropertyName("content")] + public string Content { get; init; } = string.Empty; + [JsonPropertyName("mentions")] public IEnumerable Mentions { get; init; } = []; diff --git a/src/src/StevesBot.Library/Discord/Rest/DiscordRestClient.cs b/src/src/StevesBot.Library/Discord/Rest/DiscordRestClient.cs index 28ec6e3..42666a0 100644 --- a/src/src/StevesBot.Library/Discord/Rest/DiscordRestClient.cs +++ b/src/src/StevesBot.Library/Discord/Rest/DiscordRestClient.cs @@ -1,4 +1,5 @@ using System.Net.Http.Json; +using System.Text.Json; using Microsoft.Extensions.Logging; @@ -12,6 +13,11 @@ public sealed class DiscordRestClient : IDiscordRestClient { private readonly ILogger _logger; private readonly HttpClient _httpClient; + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + }; public DiscordRestClient( ILogger logger, @@ -29,18 +35,19 @@ public sealed class DiscordRestClient : IDiscordRestClient { var gatewayEndpoint = new Uri("gateway", UriKind.Relative); var response = await _httpClient.GetAsync(gatewayEndpoint, cancellationToken); + var responseContent = await response.Content.ReadAsStringAsync(cancellationToken); if (response.IsSuccessStatusCode is false) { - _logger.LogError("Failed to get gateway URL: {StatusCode}", response.StatusCode); + _logger.LogError("Failed to get gateway URL: {StatusCode} - {Content}", response.StatusCode, responseContent); throw new DiscordRestClientException("Failed to get gateway URL."); } - var gatewayResponse = await response.Content.ReadFromJsonAsync(cancellationToken); + var gatewayResponse = JsonSerializer.Deserialize(responseContent, JsonOptions); if (gatewayResponse is null) { - _logger.LogError("Failed to deserialize gateway response."); + _logger.LogError("Failed to deserialize gateway response: {Content}", responseContent); throw new DiscordRestClientException("Failed to deserialize gateway response."); } @@ -51,18 +58,19 @@ public sealed class DiscordRestClient : IDiscordRestClient { var channelEndpoint = new Uri($"channels/{channelId}/messages", UriKind.Relative); var response = await _httpClient.PostAsJsonAsync(channelEndpoint, request, cancellationToken); + var responseContent = await response.Content.ReadAsStringAsync(cancellationToken); if (response.IsSuccessStatusCode is false) { - _logger.LogError("Failed to create message: {StatusCode}", response.StatusCode); + _logger.LogError("Failed to create message: {StatusCode} - {Content}", response.StatusCode, responseContent); throw new DiscordRestClientException("Failed to create message."); } - var discordMessage = await response.Content.ReadFromJsonAsync(cancellationToken); + var discordMessage = JsonSerializer.Deserialize(responseContent, JsonOptions); if (discordMessage is null) { - _logger.LogError("Failed to deserialize message create response."); + _logger.LogError("Failed to deserialize message create response: {Content}", responseContent); throw new DiscordRestClientException("Failed to deserialize message create response."); } @@ -73,18 +81,19 @@ public sealed class DiscordRestClient : IDiscordRestClient { var meEndpoint = new Uri($"users/@me", UriKind.Relative); var response = await _httpClient.GetAsync(meEndpoint, cancellationToken); + var responseContent = await response.Content.ReadAsStringAsync(cancellationToken); if (response.IsSuccessStatusCode is false) { - _logger.LogError("Failed to retrieve current user: {StatusCode}", response.StatusCode); + _logger.LogError("Failed to retrieve current user: {StatusCode} - {Content}", response.StatusCode, responseContent); throw new DiscordRestClientException("Failed to create message."); } - var discordUser = await response.Content.ReadFromJsonAsync(cancellationToken); + var discordUser = JsonSerializer.Deserialize(responseContent, JsonOptions); if (discordUser is null) { - _logger.LogError("Failed to deserialize user response."); + _logger.LogError("Failed to deserialize user response: {Content}", responseContent); throw new DiscordRestClientException("Failed to deserialize user response."); } diff --git a/src/src/StevesBot.Library/Discord/ServicesExtensions.cs b/src/src/StevesBot.Library/Discord/ServicesExtensions.cs index 7ad2ca9..0475df2 100644 --- a/src/src/StevesBot.Library/Discord/ServicesExtensions.cs +++ b/src/src/StevesBot.Library/Discord/ServicesExtensions.cs @@ -2,7 +2,6 @@ using Microsoft.Extensions.DependencyInjection; using StevesBot.Library.Discord.Common; using StevesBot.Library.Discord.Rest; -using StevesBot.Library.Gemini; using StevesBot.Library.Telemetry; namespace StevesBot.Library.Discord; @@ -28,16 +27,4 @@ public static class ServicesExtensions return services; } - - public static IServiceCollection AddGeminiClient(this IServiceCollection services) - { - services - .AddHttpClient(static (sp, c) => - { - throw new NotImplementedException(); - }) - .AddStandardResilienceHandler(); - - return services; - } } \ No newline at end of file diff --git a/src/src/StevesBot.Library/Gemini/GeminiClient.cs b/src/src/StevesBot.Library/Gemini/GeminiClient.cs index cfb2b13..4cfd088 100644 --- a/src/src/StevesBot.Library/Gemini/GeminiClient.cs +++ b/src/src/StevesBot.Library/Gemini/GeminiClient.cs @@ -1,3 +1,5 @@ +using System.Text; +using System.Text.Json; using Microsoft.Extensions.Logging; @@ -7,21 +9,62 @@ public sealed class GeminiClient : IGeminiClient { private readonly ILogger _logger; private readonly HttpClient _httpClient; + private readonly GeminiClientOptions _options; + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + }; public GeminiClient( ILogger logger, - HttpClient httpClient + HttpClient httpClient, + GeminiClientOptions options ) { ArgumentNullException.ThrowIfNull(logger, nameof(logger)); ArgumentNullException.ThrowIfNull(httpClient, nameof(httpClient)); + ArgumentNullException.ThrowIfNull(options, nameof(options)); _logger = logger; _httpClient = httpClient; + _options = options; } - public Task GenerateContentAsync(string input, CancellationToken ct) + public async Task GenerateContentAsync(string input, CancellationToken ct) { - throw new NotImplementedException(); + const string errorMessage = "Oh boi...I'm not sure what happened. I can't seem to respond right now."; + + try + { + var requestUri = new Uri($"/v1beta/models/{_options.ModelId}:generateContent", UriKind.Relative); + var request = Request.From(input); + var json = JsonSerializer.Serialize(request, JsonOptions); + using var content = new StringContent(json, Encoding.UTF8, "application/json"); + + using var response = await _httpClient.PostAsync(requestUri, content, ct); + var responseContent = await response.Content.ReadAsStringAsync(ct); + + if (response.IsSuccessStatusCode is false) + { + _logger.LogWarning("Request to generate content failed: {StatusCode} - {Content}", response.StatusCode, responseContent); + return errorMessage; + } + + var geminiResponse = JsonSerializer.Deserialize(responseContent, JsonOptions); + + if (geminiResponse is null) + { + _logger.LogWarning("Unable to deserialize content from response: {ResponseContent}", responseContent); + return errorMessage; + } + + return geminiResponse.GetText(); + } + catch (Exception e) + { + _logger.LogError(e, "Failed to generate content"); + return errorMessage; + } } } \ No newline at end of file diff --git a/src/src/StevesBot.Library/Gemini/GeminiClientOptions.cs b/src/src/StevesBot.Library/Gemini/GeminiClientOptions.cs new file mode 100644 index 0000000..31dbbd1 --- /dev/null +++ b/src/src/StevesBot.Library/Gemini/GeminiClientOptions.cs @@ -0,0 +1,8 @@ +namespace StevesBot.Library.Gemini; + +public sealed class GeminiClientOptions +{ + public string ApiUrl { get; init; } = string.Empty; + public string ModelId { get; init; } = string.Empty; + public string ApiKey { get; init; } = string.Empty; +} diff --git a/src/src/StevesBot.Library/Gemini/GenerationConfig.cs b/src/src/StevesBot.Library/Gemini/GenerationConfig.cs index ee5f65c..516ba02 100644 --- a/src/src/StevesBot.Library/Gemini/GenerationConfig.cs +++ b/src/src/StevesBot.Library/Gemini/GenerationConfig.cs @@ -2,5 +2,6 @@ namespace StevesBot.Library.Gemini; internal sealed record GenerationConfig { - public double? Temperature { get; init; } + public double Temperature { get; init; } = 1.0; + public int MaxOutputTokens { get; init; } = 500; } \ No newline at end of file diff --git a/src/src/StevesBot.Library/Gemini/Request.cs b/src/src/StevesBot.Library/Gemini/Request.cs index 5af2d89..71cc221 100644 --- a/src/src/StevesBot.Library/Gemini/Request.cs +++ b/src/src/StevesBot.Library/Gemini/Request.cs @@ -1,8 +1,26 @@ namespace StevesBot.Library.Gemini; -internal sealed record GeminiRequest +internal sealed record Request { - public Content? SystemInstruction { get; init; } - public required Content[] Contents { get; init; } - public GenerationConfig? GenerationConfig { get; init; } + public Content SystemInstruction { get; init; } = new(); + public Content[] Contents { get; init; } = []; + public GenerationConfig GenerationConfig { get; init; } = new(); + + public static Request From(string text) + { + return new Request() + { + Contents = [ + new() + { + Parts = [ + new() + { + Text = text, + }, + ] + }, + ] + }; + } } \ No newline at end of file diff --git a/src/src/StevesBot.Library/Gemini/ServicesExtensions.cs b/src/src/StevesBot.Library/Gemini/ServicesExtensions.cs new file mode 100644 index 0000000..6691cae --- /dev/null +++ b/src/src/StevesBot.Library/Gemini/ServicesExtensions.cs @@ -0,0 +1,30 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace StevesBot.Library.Gemini; + +public static class ServicesExtensions +{ + public static IServiceCollection AddGeminiClient(this IServiceCollection services) + { + services.AddOptions() + .BindConfiguration(nameof(GeminiClientOptions)); + + services.AddSingleton(static sp => + { + var geminiOptions = sp.GetRequiredService>().Value; + return geminiOptions; + }); + + services + .AddHttpClient(static (sp, c) => + { + var geminiOptions = sp.GetRequiredService(); + c.BaseAddress = new Uri(geminiOptions.ApiUrl); + c.DefaultRequestHeaders.Add("x-goog-api-key", geminiOptions.ApiKey); + }) + .AddStandardResilienceHandler(); + + return services; + } +} \ No newline at end of file diff --git a/src/src/StevesBot.Worker/Handlers/TaggedMessageHandler.cs b/src/src/StevesBot.Worker/Handlers/TaggedMessageHandler.cs index 6d99310..2f26115 100644 --- a/src/src/StevesBot.Worker/Handlers/TaggedMessageHandler.cs +++ b/src/src/StevesBot.Worker/Handlers/TaggedMessageHandler.cs @@ -31,10 +31,10 @@ internal static class TaggedMessageHandler logger.LogInformation("Bot tagged in message"); - // TODO: Get message content from discord - // event - var llmResponse = await geminiClient.GenerateContentAsync("Hello", cancellationToken); + var llmResponse = await geminiClient.GenerateContentAsync(mcde.Data.Content, cancellationToken); + // TODO: LLM can be wordy...discord has 2000 character limit + // on message size. Need to handle that. var request = new CreateMessageRequest( Content: llmResponse, MessageReference: new( diff --git a/src/src/StevesBot.Worker/Program.cs b/src/src/StevesBot.Worker/Program.cs index ef31b6d..76b9b2c 100644 --- a/src/src/StevesBot.Worker/Program.cs +++ b/src/src/StevesBot.Worker/Program.cs @@ -17,6 +17,8 @@ builder.Services.AddSingleton(TimeProvider.System); builder.Services.AddDiscordRestClient(); +builder.Services.AddGeminiClient(); + builder.Services.AddDiscordGatewayClient(static (client) => { client.On(DiscordEventTypes.MessageCreate, WelcomeMessageHandler.HandleAsync); diff --git a/src/src/StevesBot.Worker/Usings.cs b/src/src/StevesBot.Worker/Usings.cs index 70e9b5f..93f21d9 100644 --- a/src/src/StevesBot.Worker/Usings.cs +++ b/src/src/StevesBot.Worker/Usings.cs @@ -1,4 +1,3 @@ -global using System.Net; global using System.Net.WebSockets; global using System.Reflection; global using System.Text; @@ -11,6 +10,7 @@ global using StevesBot.Library.Discord; global using StevesBot.Library.Discord.Common; global using StevesBot.Library.Discord.Rest; global using StevesBot.Library.Discord.Rest.Requests; +global using StevesBot.Library.Gemini; global using StevesBot.Library.Telemetry; global using StevesBot.Worker; global using StevesBot.Worker.Discord; diff --git a/src/src/StevesBot.Worker/appsettings.Example.json b/src/src/StevesBot.Worker/appsettings.Example.json index 6b19aad..6023e4e 100644 --- a/src/src/StevesBot.Worker/appsettings.Example.json +++ b/src/src/StevesBot.Worker/appsettings.Example.json @@ -14,5 +14,10 @@ "ServerUrl": "ServerUrl", "ApiKeyHeader": "ApiKeyHeader", "ApiKey": "ApiKey" + }, + "GeminiClientOptions": { + "ApiUrl": "ApiUrl", + "ModelId": "ModelId", + "ApiKey": "ApiKey" } -} \ No newline at end of file +}