From 29b96eaae33805a538e68f7c3e4f7980ab203f67 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Fri, 13 Mar 2026 07:40:29 -0500 Subject: [PATCH] feat: wip on allow bot to respond to tagged messages --- .../Discord/Common/DiscordMessage.cs | 8 +++ .../Discord/Rest/DiscordRestClient.cs | 29 +++++++++- .../Discord/Rest/IDiscordRestClient.cs | 1 + .../Discord/ServicesExtensions.cs | 12 +++++ src/src/StevesBot.Library/Gemini/Candidate.cs | 6 +++ src/src/StevesBot.Library/Gemini/Content.cs | 6 +++ .../StevesBot.Library/Gemini/GeminiClient.cs | 27 ++++++++++ .../Gemini/GenerationConfig.cs | 6 +++ .../StevesBot.Library/Gemini/IGeminiClient.cs | 6 +++ src/src/StevesBot.Library/Gemini/Part.cs | 6 +++ src/src/StevesBot.Library/Gemini/Request.cs | 8 +++ src/src/StevesBot.Library/Gemini/Response.cs | 23 ++++++++ .../Gateway/Events/DiscordMessageTypes.cs | 1 + .../Events/MessageCreateDiscordEvent.cs | 1 + .../Handlers/TaggedMessageHandler.cs | 53 +++++++++++++++++++ src/src/StevesBot.Worker/Program.cs | 6 ++- 16 files changed, 195 insertions(+), 4 deletions(-) create mode 100644 src/src/StevesBot.Library/Gemini/Candidate.cs create mode 100644 src/src/StevesBot.Library/Gemini/Content.cs create mode 100644 src/src/StevesBot.Library/Gemini/GeminiClient.cs create mode 100644 src/src/StevesBot.Library/Gemini/GenerationConfig.cs create mode 100644 src/src/StevesBot.Library/Gemini/IGeminiClient.cs create mode 100644 src/src/StevesBot.Library/Gemini/Part.cs create mode 100644 src/src/StevesBot.Library/Gemini/Request.cs create mode 100644 src/src/StevesBot.Library/Gemini/Response.cs create mode 100644 src/src/StevesBot.Worker/Handlers/TaggedMessageHandler.cs diff --git a/src/src/StevesBot.Library/Discord/Common/DiscordMessage.cs b/src/src/StevesBot.Library/Discord/Common/DiscordMessage.cs index bf286ef..5cc9a2f 100644 --- a/src/src/StevesBot.Library/Discord/Common/DiscordMessage.cs +++ b/src/src/StevesBot.Library/Discord/Common/DiscordMessage.cs @@ -18,4 +18,12 @@ public sealed record DiscordMessage [JsonPropertyName("author")] public DiscordUser Author { get; init; } = new DiscordUser(); + + [JsonPropertyName("mentions")] + public IEnumerable Mentions { get; init; } = []; + + public bool MentionsUser(string userId) + { + return Mentions.Any(u => u.Id.Equals(userId, StringComparison.OrdinalIgnoreCase)); + } } \ No newline at end of file diff --git a/src/src/StevesBot.Library/Discord/Rest/DiscordRestClient.cs b/src/src/StevesBot.Library/Discord/Rest/DiscordRestClient.cs index 6995683..28ec6e3 100644 --- a/src/src/StevesBot.Library/Discord/Rest/DiscordRestClient.cs +++ b/src/src/StevesBot.Library/Discord/Rest/DiscordRestClient.cs @@ -18,8 +18,11 @@ public sealed class DiscordRestClient : IDiscordRestClient HttpClient httpClient ) { - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + ArgumentNullException.ThrowIfNull(logger, nameof(logger)); + ArgumentNullException.ThrowIfNull(httpClient, nameof(httpClient)); + + _logger = logger; + _httpClient = httpClient; } public async Task GetGatewayUrlAsync(CancellationToken cancellationToken = default) @@ -65,4 +68,26 @@ public sealed class DiscordRestClient : IDiscordRestClient return discordMessage; } + + public async Task GetMeAsync(CancellationToken cancellationToken) + { + var meEndpoint = new Uri($"users/@me", UriKind.Relative); + var response = await _httpClient.GetAsync(meEndpoint, cancellationToken); + + if (response.IsSuccessStatusCode is false) + { + _logger.LogError("Failed to retrieve current user: {StatusCode}", response.StatusCode); + throw new DiscordRestClientException("Failed to create message."); + } + + var discordUser = await response.Content.ReadFromJsonAsync(cancellationToken); + + if (discordUser is null) + { + _logger.LogError("Failed to deserialize user response."); + throw new DiscordRestClientException("Failed to deserialize user response."); + } + + return discordUser; + } } \ No newline at end of file diff --git a/src/src/StevesBot.Library/Discord/Rest/IDiscordRestClient.cs b/src/src/StevesBot.Library/Discord/Rest/IDiscordRestClient.cs index 8368ff5..0b3f3df 100644 --- a/src/src/StevesBot.Library/Discord/Rest/IDiscordRestClient.cs +++ b/src/src/StevesBot.Library/Discord/Rest/IDiscordRestClient.cs @@ -7,4 +7,5 @@ public interface IDiscordRestClient { Task GetGatewayUrlAsync(CancellationToken cancellationToken = default); Task CreateMessageAsync(string channelId, CreateMessageRequest request, CancellationToken cancellationToken = default); + Task GetMeAsync(CancellationToken cancellationToken); } \ No newline at end of file diff --git a/src/src/StevesBot.Library/Discord/ServicesExtensions.cs b/src/src/StevesBot.Library/Discord/ServicesExtensions.cs index 71a9183..7ad2ca9 100644 --- a/src/src/StevesBot.Library/Discord/ServicesExtensions.cs +++ b/src/src/StevesBot.Library/Discord/ServicesExtensions.cs @@ -2,6 +2,7 @@ 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,4 +29,15 @@ 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/Candidate.cs b/src/src/StevesBot.Library/Gemini/Candidate.cs new file mode 100644 index 0000000..da4f229 --- /dev/null +++ b/src/src/StevesBot.Library/Gemini/Candidate.cs @@ -0,0 +1,6 @@ +namespace StevesBot.Library.Gemini; + +internal sealed record Candidate +{ + public Content Content { get; init; } = new(); +} \ No newline at end of file diff --git a/src/src/StevesBot.Library/Gemini/Content.cs b/src/src/StevesBot.Library/Gemini/Content.cs new file mode 100644 index 0000000..78f5584 --- /dev/null +++ b/src/src/StevesBot.Library/Gemini/Content.cs @@ -0,0 +1,6 @@ +namespace StevesBot.Library.Gemini; + +internal sealed record Content +{ + public Part[] Parts { get; init; } = []; +} \ No newline at end of file diff --git a/src/src/StevesBot.Library/Gemini/GeminiClient.cs b/src/src/StevesBot.Library/Gemini/GeminiClient.cs new file mode 100644 index 0000000..cfb2b13 --- /dev/null +++ b/src/src/StevesBot.Library/Gemini/GeminiClient.cs @@ -0,0 +1,27 @@ + +using Microsoft.Extensions.Logging; + +namespace StevesBot.Library.Gemini; + +public sealed class GeminiClient : IGeminiClient +{ + private readonly ILogger _logger; + private readonly HttpClient _httpClient; + + public GeminiClient( + ILogger logger, + HttpClient httpClient + ) + { + ArgumentNullException.ThrowIfNull(logger, nameof(logger)); + ArgumentNullException.ThrowIfNull(httpClient, nameof(httpClient)); + + _logger = logger; + _httpClient = httpClient; + } + + public Task GenerateContentAsync(string input, CancellationToken ct) + { + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/src/src/StevesBot.Library/Gemini/GenerationConfig.cs b/src/src/StevesBot.Library/Gemini/GenerationConfig.cs new file mode 100644 index 0000000..ee5f65c --- /dev/null +++ b/src/src/StevesBot.Library/Gemini/GenerationConfig.cs @@ -0,0 +1,6 @@ +namespace StevesBot.Library.Gemini; + +internal sealed record GenerationConfig +{ + public double? Temperature { get; init; } +} \ No newline at end of file diff --git a/src/src/StevesBot.Library/Gemini/IGeminiClient.cs b/src/src/StevesBot.Library/Gemini/IGeminiClient.cs new file mode 100644 index 0000000..b33eea5 --- /dev/null +++ b/src/src/StevesBot.Library/Gemini/IGeminiClient.cs @@ -0,0 +1,6 @@ +namespace StevesBot.Library.Gemini; + +public interface IGeminiClient +{ + Task GenerateContentAsync(string input, CancellationToken ct); +} \ No newline at end of file diff --git a/src/src/StevesBot.Library/Gemini/Part.cs b/src/src/StevesBot.Library/Gemini/Part.cs new file mode 100644 index 0000000..725eb3d --- /dev/null +++ b/src/src/StevesBot.Library/Gemini/Part.cs @@ -0,0 +1,6 @@ +namespace StevesBot.Library.Gemini; + +internal sealed record Part +{ + public required string Text { get; init; } +} \ No newline at end of file diff --git a/src/src/StevesBot.Library/Gemini/Request.cs b/src/src/StevesBot.Library/Gemini/Request.cs new file mode 100644 index 0000000..5af2d89 --- /dev/null +++ b/src/src/StevesBot.Library/Gemini/Request.cs @@ -0,0 +1,8 @@ +namespace StevesBot.Library.Gemini; + +internal sealed record GeminiRequest +{ + public Content? SystemInstruction { get; init; } + public required Content[] Contents { get; init; } + public GenerationConfig? GenerationConfig { get; init; } +} \ No newline at end of file diff --git a/src/src/StevesBot.Library/Gemini/Response.cs b/src/src/StevesBot.Library/Gemini/Response.cs new file mode 100644 index 0000000..3c70c67 --- /dev/null +++ b/src/src/StevesBot.Library/Gemini/Response.cs @@ -0,0 +1,23 @@ +namespace StevesBot.Library.Gemini; + +internal sealed record Response +{ + public Candidate[] Candidates { get; init; } = []; + + public string GetText() + { + if (Candidates.Length is 0) + { + return string.Empty; + } + + var parts = Candidates.First().Content.Parts; + + if (parts.Length is 0) + { + return string.Empty; + } + + return parts.First().Text; + } +} \ No newline at end of file diff --git a/src/src/StevesBot.Worker/Discord/Gateway/Events/DiscordMessageTypes.cs b/src/src/StevesBot.Worker/Discord/Gateway/Events/DiscordMessageTypes.cs index 446078d..bd02325 100644 --- a/src/src/StevesBot.Worker/Discord/Gateway/Events/DiscordMessageTypes.cs +++ b/src/src/StevesBot.Worker/Discord/Gateway/Events/DiscordMessageTypes.cs @@ -2,5 +2,6 @@ namespace StevesBot.Worker.Discord.Gateway.Events; internal static class DiscordMessageTypes { + public const int Default = 0; public const int UserJoin = 7; } \ No newline at end of file diff --git a/src/src/StevesBot.Worker/Discord/Gateway/Events/MessageCreateDiscordEvent.cs b/src/src/StevesBot.Worker/Discord/Gateway/Events/MessageCreateDiscordEvent.cs index 23e6645..1cb0d9b 100644 --- a/src/src/StevesBot.Worker/Discord/Gateway/Events/MessageCreateDiscordEvent.cs +++ b/src/src/StevesBot.Worker/Discord/Gateway/Events/MessageCreateDiscordEvent.cs @@ -1,3 +1,4 @@ + namespace StevesBot.Worker.Discord.Gateway.Events; internal sealed record MessageCreateDiscordEvent : DispatchDiscordEvent diff --git a/src/src/StevesBot.Worker/Handlers/TaggedMessageHandler.cs b/src/src/StevesBot.Worker/Handlers/TaggedMessageHandler.cs new file mode 100644 index 0000000..6d99310 --- /dev/null +++ b/src/src/StevesBot.Worker/Handlers/TaggedMessageHandler.cs @@ -0,0 +1,53 @@ +using StevesBot.Library.Gemini; + +namespace StevesBot.Worker.Handlers; + +internal static class TaggedMessageHandler +{ + public static async Task HandleAsync( + DiscordEvent discordEvent, + IServiceProvider serviceProvider, + CancellationToken cancellationToken = default + ) + { + var logger = serviceProvider.GetRequiredService>(); + var discordRestClient = serviceProvider.GetRequiredService(); + var geminiClient = serviceProvider.GetRequiredService(); + + if ( + discordEvent is not MessageCreateDiscordEvent mcde || + mcde.IsMessageType(DiscordMessageTypes.Default) is false + ) + { + return; + } + + var botUser = await discordRestClient.GetMeAsync(cancellationToken); + + if (mcde.Data.MentionsUser(botUser.Id) is false) + { + return; + } + + logger.LogInformation("Bot tagged in message"); + + // TODO: Get message content from discord + // event + var llmResponse = await geminiClient.GenerateContentAsync("Hello", cancellationToken); + + var request = new CreateMessageRequest( + Content: llmResponse, + MessageReference: new( + Type: DiscordMessageReferenceTypes.Default, + MessageId: mcde.Data.Id, + ChannelId: mcde.Data.ChannelId, + GuildId: mcde.Data.GuildId, + FailIfNotExists: false + ) + ); + + var message = await discordRestClient.CreateMessageAsync(mcde.Data.ChannelId, request, cancellationToken); + + logger.LogInformation("Responded to tagged message with Id: {MessageId} for user: {UserId}", message.Id, mcde.Data.Author.Id); + } +} \ No newline at end of file diff --git a/src/src/StevesBot.Worker/Program.cs b/src/src/StevesBot.Worker/Program.cs index 38f744e..ef31b6d 100644 --- a/src/src/StevesBot.Worker/Program.cs +++ b/src/src/StevesBot.Worker/Program.cs @@ -18,8 +18,10 @@ builder.Services.AddSingleton(TimeProvider.System); builder.Services.AddDiscordRestClient(); builder.Services.AddDiscordGatewayClient(static (client) => - client.On(DiscordEventTypes.MessageCreate, WelcomeMessageHandler.HandleAsync) -); +{ + client.On(DiscordEventTypes.MessageCreate, WelcomeMessageHandler.HandleAsync); + client.On(DiscordEventTypes.MessageCreate, TaggedMessageHandler.HandleAsync); +}); builder.Services.AddHostedService();