add ai commands

This commit is contained in:
Garret Patti
2026-07-10 18:20:33 -04:00
parent 3064e93011
commit 3a37994498
5 changed files with 460 additions and 12 deletions

View File

@@ -10,6 +10,7 @@ public class BotService
{
private readonly DiscordSocketClient _client;
private readonly InteractionService _interactionService;
private readonly LlmService _llmService;
private readonly ILogger<BotService> _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<BotService> 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}");

226
Services/LlmService.cs Normal file
View File

@@ -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<LlmService> _logger;
private readonly string _endpoint;
private readonly string _apiKey;
private readonly string _model;
private readonly int _maxTokens;
private readonly double _temperature;
private readonly ConcurrentDictionary<ulong, List<ChatMessage>> _histories = new();
private const int MaxHistoryChars = 20000;
public LlmService(IConfiguration configuration, ILogger<LlmService> 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<string> SendMessageAsync(ulong channelId, string user, string message)
{
var history = _histories.GetOrAdd(channelId, _ => new List<ChatMessage>());
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<ChatMessage> 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<ChatMessage> 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; } = "";
}
}