From 3a37994498dbe93b2ae95d041f3dce232f982f77 Mon Sep 17 00:00:00 2001 From: Garret Patti <42485635+garretpatti@users.noreply.github.com> Date: Fri, 10 Jul 2026 18:20:33 -0400 Subject: [PATCH] add ai commands --- Commands/LlmCommands.cs | 118 +++++++++++++++++++++ Program.cs | 3 +- README.md | 22 ++-- Services/BotService.cs | 103 ++++++++++++++++++ Services/LlmService.cs | 226 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 460 insertions(+), 12 deletions(-) create mode 100644 Commands/LlmCommands.cs create mode 100644 Services/LlmService.cs diff --git a/Commands/LlmCommands.cs b/Commands/LlmCommands.cs new file mode 100644 index 0000000..2f70342 --- /dev/null +++ b/Commands/LlmCommands.cs @@ -0,0 +1,118 @@ +using Discord; +using Discord.Interactions; +using Microsoft.Extensions.Logging; +using TentacleBot.Services; + +namespace TentacleBot.Commands; + +[Group("llm", "LLM chat commands")] +public class LlmCommands : InteractionModuleBase +{ + private readonly LlmService _llmService; + private readonly ILogger _logger; + + public LlmCommands(LlmService llmService, ILogger logger) + { + _llmService = llmService; + _logger = logger; + } + + [SlashCommand("chat", "Chat with the LLM model")] + public async Task HandleChatAsync( + [Summary("prompt", "Your message to the LLM")] string prompt) + { + await DeferAsync(); + + var channelId = Context.Channel.Id; + var userName = Context.User.Username; + + try + { + var accumulated = ""; + + await foreach (var text in _llmService.SendMessageAsync(channelId, userName, prompt)) + { + accumulated = text; + } + + if (string.IsNullOrWhiteSpace(accumulated)) + { + await FollowupAsync(embed: new EmbedBuilder() + .WithTitle("LLM Response") + .WithDescription("The model returned an empty response.") + .WithColor(Color.Orange) + .Build()); + return; + } + + if (accumulated.Length <= 4096) + { + await FollowupAsync(embed: new EmbedBuilder() + .WithTitle($"Response from {Context.Client.CurrentUser?.Username}") + .WithDescription(Format.Code(accumulated)) + .WithColor(Color.DarkTeal) + .WithTimestamp(DateTimeOffset.UtcNow) + .Build()); + } + else + { + var truncated = accumulated.Substring(0, 4096); + await FollowupAsync(embed: new EmbedBuilder() + .WithTitle("LLM Response (truncated)") + .WithDescription($"{Format.Code(truncated)}\n\n*Response exceeded Discord's message limit and was truncated.*") + .WithColor(Color.Orange) + .WithTimestamp(DateTimeOffset.UtcNow) + .Build()); + + var remaining = accumulated.Substring(4096); + var continuation = "Continued..."; + + while (remaining.Length > 0) + { + var chunk = remaining.Length > 4096 ? remaining.Substring(0, 4096) : remaining; + continuation += $"\n\n{Format.Code(chunk)}"; + remaining = remaining.Substring(4096); + + try + { + await FollowupAsync(continuation, embed: new EmbedBuilder() + .WithTitle("LLM Response (continued)") + .WithColor(Color.DarkTeal) + .Build()); + continuation = ""; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Error sending continuation message"); + break; + } + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in LLM chat command"); + await FollowupAsync(embed: new EmbedBuilder() + .WithTitle("Error") + .WithDescription("An error occurred while communicating with the LLM.") + .WithColor(Color.Red) + .WithTimestamp(DateTimeOffset.UtcNow) + .Build()); + } + } + + // [SlashCommand("reset", "Reset conversation history for this channel")] + // public async Task HandleResetAsync() + // { + // _llmService.ResetHistory(Context.Channel.Id); + + // var embed = new EmbedBuilder() + // .WithTitle("History Reset") + // .WithDescription("Conversation history for this channel has been cleared.") + // .WithColor(Color.Green) + // .WithTimestamp(DateTimeOffset.UtcNow) + // .Build(); + + // await RespondAsync(embed: embed); + // } +} diff --git a/Program.cs b/Program.cs index b8d1fad..f8ff836 100644 --- a/Program.cs +++ b/Program.cs @@ -31,7 +31,8 @@ var services = new ServiceCollection() LogLevel = LogSeverity.Info, UseCompiledLambda = true, })) - .AddSingleton(); + .AddSingleton() + .AddSingleton(); var serviceProvider = services.BuildServiceProvider(); diff --git a/README.md b/README.md index a380196..85872e1 100644 --- a/README.md +++ b/README.md @@ -80,17 +80,17 @@ docker compose down ``` TentacleBot/ -„¥„Ÿ„Ÿ Program.cs # Entry point and DI setup -„¥„Ÿ„Ÿ Services/ -„  „¤„Ÿ„Ÿ BotService.cs # Core bot service -„¥„Ÿ„Ÿ Commands/ -„  „¤„Ÿ„Ÿ GeneralCommands.cs # Example slash commands -„¥„Ÿ„Ÿ appsettings.json # Configuration -„¥„Ÿ„Ÿ Dockerfile # Container image definition -„¥„Ÿ„Ÿ docker-compose.yml # Local compose setup -„¥„Ÿ„Ÿ .env.example # Environment variable template -„¥„Ÿ„Ÿ TentacleBot.csproj # Project file -„¤„Ÿ„Ÿ README.md # This file +├── Program.cs # Entry point and DI setup +├── Services/ +│ └── BotService.cs # Core bot service +├── Commands/ +│ └── GeneralCommands.cs # Example slash commands +├── appsettings.json # Configuration +├── Dockerfile # Container image definition +├── docker-compose.yml # Local compose setup +├── .env.example # Environment variable template +├── TentacleBot.csproj # Project file +└── README.md # This file ``` ## Example Commands diff --git a/Services/BotService.cs b/Services/BotService.cs index 42cf245..6f990d0 100644 --- a/Services/BotService.cs +++ b/Services/BotService.cs @@ -10,6 +10,7 @@ public class BotService { private readonly DiscordSocketClient _client; private readonly InteractionService _interactionService; + private readonly LlmService _llmService; private readonly ILogger _logger; private readonly IConfiguration _configuration; private readonly IServiceProvider _serviceProvider; @@ -17,12 +18,14 @@ public class BotService public BotService( DiscordSocketClient client, InteractionService interactionService, + LlmService llmService, ILogger logger, IConfiguration configuration, IServiceProvider serviceProvider) { _client = client; _interactionService = interactionService; + _llmService = llmService; _logger = logger; _configuration = configuration; _serviceProvider = serviceProvider; @@ -35,6 +38,7 @@ public class BotService _client.Ready += ClientReadyAsync; _client.InteractionCreated += InteractionCreatedAsync; + _client.MessageReceived += MessageReceivedAsync; var token = Environment.GetEnvironmentVariable("DISCORD_BOT_TOKEN"); if (string.IsNullOrWhiteSpace(token)) @@ -87,6 +91,105 @@ public class BotService } } + private async Task MessageReceivedAsync(SocketMessage socketMessage) + { + if (socketMessage is not SocketUserMessage message) + return; + + if (message.Source != MessageSource.User || message.Author.IsBot || message.Author.IsWebhook) + return; + + if (_client.CurrentUser is null) + return; + + var botId = _client.CurrentUser.Id; + var hasMention = message.MentionedUsers.Any(u => u.Id == botId); + var isReplyToBot = false; + + if (message.Reference?.MessageId.IsSpecified == true) + { + try + { + var referenced = message.ReferencedMessage ?? + await message.Channel.GetMessageAsync(message.Reference.MessageId.Value); + + isReplyToBot = referenced?.Author.Id == botId; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Unable to resolve referenced message for reply detection."); + } + } + + if (!hasMention && !isReplyToBot) + return; + + var prompt = hasMention ? RemoveBotMentions(message.Content, botId) : message.Content.Trim(); + + if (string.IsNullOrWhiteSpace(prompt)) + { + await message.ReplyAsync("Please include a prompt after mentioning me."); + return; + } + + try + { + var accumulated = string.Empty; + + await foreach (var text in _llmService.SendMessageAsync(message.Channel.Id, message.Author.Username, prompt)) + { + accumulated = text; + } + + if (string.IsNullOrWhiteSpace(accumulated)) + { + await message.ReplyAsync("The model returned an empty response."); + return; + } + + await SendChunkedReplyAsync(message, accumulated); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error handling mention/reply LLM chat in channel {ChannelId}", message.Channel.Id); + await message.ReplyAsync("I hit an error while generating a response."); + } + } + + private static async Task SendChunkedReplyAsync(SocketUserMessage message, string response) + { + const int maxChunkLength = 2000; + + for (var start = 0; start < response.Length; start += maxChunkLength) + { + var length = Math.Min(maxChunkLength, response.Length - start); + var chunk = response.Substring(start, length); + + if (start == 0) + { + await message.ReplyAsync(chunk); + } + else + { + await message.Channel.SendMessageAsync(chunk); + } + } + } + + private static string RemoveBotMentions(string content, ulong botId) + { + if (string.IsNullOrWhiteSpace(content)) + return string.Empty; + + var rawMention = $"<@{botId}>"; + var nickMention = $"<@!{botId}>"; + + return content + .Replace(rawMention, string.Empty, StringComparison.Ordinal) + .Replace(nickMention, string.Empty, StringComparison.Ordinal) + .Trim(); + } + private static Task LogAsync(LogMessage message) { Console.WriteLine($"[{message.Severity}] {message.Source}: {message.Message}"); diff --git a/Services/LlmService.cs b/Services/LlmService.cs new file mode 100644 index 0000000..525603f --- /dev/null +++ b/Services/LlmService.cs @@ -0,0 +1,226 @@ +using System.Collections.Concurrent; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Configuration; + +namespace TentacleBot.Services; + +public class LlmService +{ + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + private readonly string _endpoint; + private readonly string _apiKey; + private readonly string _model; + private readonly int _maxTokens; + private readonly double _temperature; + private readonly ConcurrentDictionary> _histories = new(); + private const int MaxHistoryChars = 20000; + + public LlmService(IConfiguration configuration, ILogger logger) + { + _logger = logger; + _httpClient = new HttpClient(); + + _endpoint = configuration["Llm:Endpoint"] ?? throw new InvalidOperationException("Llm:Endpoint is not configured"); + _apiKey = configuration["Llm:ApiKey"] ?? throw new InvalidOperationException("Llm:ApiKey is not configured"); + _model = configuration["Llm:Model"] ?? throw new InvalidOperationException("Llm:Model is not configured"); + _maxTokens = int.Parse(configuration["Llm:MaxTokens"] ?? "1024"); + _temperature = double.Parse(configuration["Llm:Temperature"] ?? "0.7"); + + _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _apiKey); + _httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + } + + public async IAsyncEnumerable SendMessageAsync(ulong channelId, string user, string message) + { + var history = _histories.GetOrAdd(channelId, _ => new List()); + + history.Add(new ChatMessage { Role = "user", Content = message }); + + TrimHistory(history); + + var request = new ChatRequest + { + Model = _model, + Messages = history.Select(m => new ChatMessage { Role = m.Role, Content = m.Content }).ToList(), + MaxTokens = _maxTokens, + Temperature = (float)_temperature, + Stream = true + }; + + var json = JsonSerializer.Serialize(request, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); + var content = new StringContent(json, Encoding.UTF8, "application/json"); + + using var response = await _httpClient.PostAsync(_endpoint, content); + + if (!response.IsSuccessStatusCode) + { + var errorBody = await response.Content.ReadAsStringAsync(); + _logger.LogError("LLM API error ({StatusCode}): {Error}", response.StatusCode, errorBody); + throw new HttpRequestException($"LLM API returned {response.StatusCode}: {errorBody}"); + } + + var accumulated = new StringBuilder(); + + var mediaType = response.Content.Headers.ContentType?.MediaType; + if (string.Equals(mediaType, "text/event-stream", StringComparison.OrdinalIgnoreCase)) + { + await using var stream = await response.Content.ReadAsStreamAsync(); + using var reader = new StreamReader(stream); + + while (await reader.ReadLineAsync() is { } line) + { + if (!line.StartsWith("data:", StringComparison.Ordinal)) + continue; + + var data = line.Substring(5).Trim(); + if (data == "[DONE]") + break; + + var token = TryParseStreamToken(data); + if (!string.IsNullOrEmpty(token)) + { + accumulated.Append(token); + yield return accumulated.ToString(); + } + } + } + else + { + var body = await response.Content.ReadAsStringAsync(); + var fullText = TryParseNonStreamContent(body); + if (!string.IsNullOrWhiteSpace(fullText)) + { + accumulated.Append(fullText); + yield return accumulated.ToString(); + } + } + + if (accumulated.Length > 0) + { + history.Add(new ChatMessage { Role = "assistant", Content = accumulated.ToString() }); + TrimHistory(history); + } + } + + private static string? TryParseStreamToken(string data) + { + try + { + using var doc = JsonDocument.Parse(data); + + if (doc.RootElement.TryGetProperty("choices", out var choices) && + choices.ValueKind == JsonValueKind.Array && + choices.GetArrayLength() > 0) + { + var firstChoice = choices[0]; + + if (firstChoice.TryGetProperty("delta", out var delta) && + delta.ValueKind == JsonValueKind.Object && + delta.TryGetProperty("content", out var deltaContent) && + deltaContent.ValueKind == JsonValueKind.String) + { + return deltaContent.GetString(); + } + + if (firstChoice.TryGetProperty("text", out var text) && + text.ValueKind == JsonValueKind.String) + { + return text.GetString(); + } + } + + if (doc.RootElement.TryGetProperty("delta", out var rootDelta) && + rootDelta.ValueKind == JsonValueKind.Object && + rootDelta.TryGetProperty("content", out var rootContent) && + rootContent.ValueKind == JsonValueKind.String) + { + return rootContent.GetString(); + } + + if (doc.RootElement.TryGetProperty("content", out var content) && + content.ValueKind == JsonValueKind.String) + { + return content.GetString(); + } + + return null; + } + catch + { + return null; + } + } + + private static string? TryParseNonStreamContent(string body) + { + try + { + using var doc = JsonDocument.Parse(body); + + if (doc.RootElement.TryGetProperty("choices", out var choices) && + choices.ValueKind == JsonValueKind.Array && + choices.GetArrayLength() > 0) + { + var firstChoice = choices[0]; + + if (firstChoice.TryGetProperty("message", out var message) && + message.ValueKind == JsonValueKind.Object && + message.TryGetProperty("content", out var messageContent) && + messageContent.ValueKind == JsonValueKind.String) + { + return messageContent.GetString(); + } + + if (firstChoice.TryGetProperty("text", out var text) && + text.ValueKind == JsonValueKind.String) + { + return text.GetString(); + } + } + + if (doc.RootElement.TryGetProperty("content", out var content) && + content.ValueKind == JsonValueKind.String) + { + return content.GetString(); + } + + return null; + } + catch + { + return null; + } + } + + public void ResetHistory(ulong channelId) + { + _histories.TryRemove(channelId, out _); + } + + private void TrimHistory(List history) + { + while (history.Sum(m => m.Content.Length) > MaxHistoryChars && history.Count > 2) + { + history.RemoveAt(0); + } + } + + private record ChatRequest + { + public string Model { get; init; } = ""; + public List Messages { get; init; } = new(); + public int MaxTokens { get; init; } + public float Temperature { get; init; } + public bool Stream { get; init; } + } + + public record ChatMessage + { + public string Role { get; set; } = ""; + public string Content { get; set; } = ""; + } +}