Files
KfChatDotNet/KfChatDotNetBot/Commands/NoraCommand.cs

275 lines
12 KiB
C#

using System.Text.RegularExpressions;
using Humanizer;
using KfChatDotNetBot.Extensions;
using KfChatDotNetBot.Models;
using KfChatDotNetBot.Models.DbModels;
using KfChatDotNetBot.Services;
using KfChatDotNetBot.Settings;
using KfChatDotNetWsClient.Models.Events;
using NLog;
namespace KfChatDotNetBot.Commands;
/// <summary>
/// The !nora command allows users to interact with Grok AI (xAI) through the chat.
/// All messages are moderated via OpenAI's Moderation API to filter illegal content
/// while allowing profanity and general offensive language.
///
/// Supports per-chatter or per-room conversation context with automatic compaction.
/// Use "!nora reset" to clear your conversation history.
///
/// Flow:
/// 1. Validate input (15 words max, 140 chars max)
/// 2. Moderate content via OpenAI (blocks illegal, allows profanity)
/// 3. Build conversation context (if enabled)
/// 4. Send to Grok AI for response
/// 5. Store exchange in context and compact if needed
/// 6. Post formatted response to chat
///
/// Configuration required:
/// - OpenAi.ApiKey: OpenAI API key for moderation (free)
/// - Grok.ApiKey: xAI API key for Grok (~$0.20 per 1M input tokens)
/// - Grok.Nora.ContextMode: perChatter, perRoom, or disabled
///
/// See NORA_SETUP.md for detailed setup instructions.
/// </summary>
public class NoraCommand : ICommand
{
private static readonly Logger Logger = LogManager.GetCurrentClassLogger();
public List<Regex> Patterns => [
new Regex(@"^nora\s+(?<message>.+)", RegexOptions.IgnoreCase)
];
public string HelpText => "Ask Nora AI a question (max 15 words, 140 chars). Use '!nora reset' to clear context.";
public UserRight RequiredRight => UserRight.Loser;
public TimeSpan Timeout => TimeSpan.FromSeconds(30);
public RateLimitOptionsModel RateLimitOptions => new()
{
Window = TimeSpan.FromMinutes(1),
MaxInvocations = 3,
Flags = RateLimitFlags.None
};
public bool WhisperCanInvoke => false;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user,
GroupCollection arguments, CancellationToken ctx)
{
var userMessage = arguments["message"].Value.Trim();
var manager = new ConversationContextManager();
// Handle !nora reset — clear conversation context
if (userMessage.Equals("reset", StringComparison.OrdinalIgnoreCase))
{
var modeSetting = await SettingsProvider.GetValueAsync(BuiltIn.Keys.GrokNoraContextMode);
var mode = modeSetting.Value ?? "perChatter";
if (mode.Equals("disabled", StringComparison.OrdinalIgnoreCase))
{
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, conversation context is disabled.",
true,
autoDeleteAfter: TimeSpan.FromSeconds(15));
return;
}
var resetKey = ConversationContextManager.GetContextKeyAsync(mode, user.KfId, message.RoomId!.Value);
var cleared = await manager.ClearContextAsync(resetKey);
await botInstance.SendChatMessageAsync(
cleared
? $"{user.FormatUsername()}, your conversation context has been cleared."
: $"{user.FormatUsername()}, you don't have an active conversation context.",
true,
autoDeleteAfter: TimeSpan.FromSeconds(15));
return;
}
var maxWords = (await SettingsProvider.GetValueAsync(BuiltIn.Keys.GrokNoraMaxWords)).ToType<int>();
var maxCharacters = (await SettingsProvider.GetValueAsync(BuiltIn.Keys.GrokNoraMaxCharacters)).ToType<int>();
// Validate word count
var wordCount = userMessage.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length;
if (wordCount > maxWords)
{
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, your message has {wordCount} words. Maximum is {maxWords} words.",
true,
autoDeleteAfter: TimeSpan.FromSeconds(15));
return;
}
// Validate character count
if (userMessage.Length > maxCharacters)
{
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, your message has {userMessage.Length} characters. Maximum is {maxCharacters} characters.",
true,
autoDeleteAfter: TimeSpan.FromSeconds(15));
return;
}
// Step 1: Moderate the content
var moderationEnabled =
(await SettingsProvider.GetValueAsync(BuiltIn.Keys.OpenAiModerationEnabled)).ToBoolean();
if (moderationEnabled)
{
var moderationResult = await OpenAiModeration.ModerateContentAsync(userMessage);
if (moderationResult == null)
{
Logger.Warn($"Moderation API failed for user {user.KfUsername}, blocking message as safety precaution");
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, moderation service is currently unavailable. Please try again later.",
true,
autoDeleteAfter: TimeSpan.FromSeconds(15));
return;
}
if (OpenAiModeration.IsIllegalContent(moderationResult.Categories))
{
Logger.Warn($"User {user.KfUsername} attempted to send illegal content via Nora command: {userMessage}");
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, your message was blocked for containing illegal content.",
true,
autoDeleteAfter: TimeSpan.FromSeconds(15));
return;
}
if (moderationResult.Flagged)
{
Logger.Info($"User {user.KfUsername} sent flagged but allowed content (profanity/offensive): {userMessage}");
}
}
// Step 2: Build conversation context and get Grok AI response
var basePrompt = (await SettingsProvider.GetValueAsync(BuiltIn.Keys.GrokNoraPrompt)).Value;
if (basePrompt == null)
{
Logger.Error("Nora prompt file is missing or unreadable");
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, Nora's prompt file is missing. Please check the server configuration.",
true,
autoDeleteAfter: TimeSpan.FromSeconds(15));
return;
}
var settings = await SettingsProvider.GetMultipleValuesAsync([
BuiltIn.Keys.GrokNoraContextMode,
BuiltIn.Keys.GrokNoraUserInfoEnabled
]);
var systemPrompt = basePrompt;
var contextMode = settings[BuiltIn.Keys.GrokNoraContextMode].Value ?? "perChatter";
var contextDisabled = contextMode.Equals("disabled", StringComparison.OrdinalIgnoreCase);
// Compute context key once (used for mood and later for context messages)
string? contextKey = null;
if (!contextDisabled)
contextKey = ConversationContextManager.GetContextKeyAsync(contextMode, user.KfId, message.RoomId!.Value);
// Optionally inject user info into the system prompt
var userInfoEnabled = settings[BuiltIn.Keys.GrokNoraUserInfoEnabled].Value?.Equals("true", StringComparison.OrdinalIgnoreCase) == true;
if (userInfoEnabled)
{
var infoParts = new List<string>
{
$"Username: {user.KfUsername}",
$"Permission level: {user.UserRight.Humanize()}"
};
var gambler = await Money.GetGamblerEntityAsync(user.Id, ct: ctx);
if (gambler is { State: GamblerState.Active })
{
infoParts.Add($"Kasino balance: {await gambler.Balance.FormatKasinoCurrencyAsync()}");
infoParts.Add($"Total wagered: {await gambler.TotalWagered.FormatKasinoCurrencyAsync()}");
var vipPerk = await Money.GetVipLevelAsync(gambler, ctx);
if (vipPerk != null)
{
infoParts.Add($"VIP rank: {vipPerk.PerkName} (Tier {vipPerk.PerkTier})");
}
else
{
infoParts.Add("VIP rank: None (hasn't reached first VIP level)");
}
}
else
{
infoParts.Add("Kasino status: Permanently excluded");
}
systemPrompt += "\n\nThe customer you are currently speaking to has the following profile:\n" + string.Join("\n", infoParts);
}
// Inject mood into system prompt
var mood = contextKey != null
? await manager.GetOrAssignMoodAsync(contextKey)
: await ConversationContextManager.GetRandomMoodAsync();
systemPrompt += "\n\n" + mood;
string? grokResponse;
if (contextDisabled)
{
// Stateless mode — same as before
grokResponse = await GrokApi.GetChatCompletionAsync(systemPrompt, userMessage);
}
else
{
// Context-aware mode
// Get existing context messages and append current user message
// In perRoom mode, prefix with username so the AI knows who said what
var contentForContext = contextMode.Equals("perroom", StringComparison.OrdinalIgnoreCase)
? $"{user.KfUsername}: {userMessage}"
: userMessage;
var contextMessages = await manager.GetMessagesForApiAsync(contextKey!);
contextMessages.Add(new ConversationMessage { Role = "user", Content = contentForContext });
grokResponse = await GrokApi.GetChatCompletionAsync(systemPrompt, contextMessages);
if (grokResponse != null)
{
// Store the exchange in context
await manager.AddMessageAsync(contextKey!, "user", contentForContext);
await manager.AddMessageAsync(contextKey!, "assistant", grokResponse);
// Compact if context is getting too large
await manager.CompactIfNeededAsync(contextKey!);
}
}
if (grokResponse == null)
{
Logger.Error($"Grok API failed for user {user.KfUsername}");
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, Nora is currently unavailable. Please try again later.",
true,
autoDeleteAfter: TimeSpan.FromSeconds(15));
return;
}
// Step 3: Send response to chat with user avatar
// var avatarTag = "";
// if (message.Author.AvatarUrl != null)
// {
// var avatarPath = message.Author.AvatarUrl.IsAbsoluteUri
// ? message.Author.AvatarUrl.PathAndQuery
// : message.Author.AvatarUrl.OriginalString;
// avatarPath = avatarPath.Replace("/data/avatars/m/", "/data/avatars/s/");
// avatarTag = $"[img]https://uploads.kiwifarms.st{avatarPath}[/img] ";
// }
// var formattedResponse = $"{avatarTag}[b]Nora to {user.FormatUsername()}:[/b] {grokResponse}";
var formattedResponse = $"[b]Nora to {user.FormatUsername()}:[/b] {grokResponse}";
var autoDeleteAfter =
TimeSpan.FromMilliseconds((await SettingsProvider.GetValueAsync(BuiltIn.Keys.GrokNoraAutoDeleteDelay))
.ToType<int>());
await botInstance.SendChatMessageAsync(
formattedResponse,
true, autoDeleteAfter: autoDeleteAfter);
}
}