This commit is contained in:
xXCryingLaughingXx
2026-02-17 20:24:30 -06:00
committed by GitHub
parent f701cae171
commit 30d9f48d2e
14 changed files with 2058 additions and 1 deletions

View File

@@ -0,0 +1,302 @@
using System.Text.RegularExpressions;
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();
private static readonly string? PromptFilePath = FindPromptFile();
private static string? _cachedPrompt;
private static DateTime _cachedPromptLastModified;
private static string? FindPromptFile()
{
// Check next to the executable first, then fall back to the Commands directory in the source tree
var exeDir = AppContext.BaseDirectory;
var candidate = Path.Combine(exeDir, "Commands", "NoraPrompt.txt");
if (File.Exists(candidate)) return candidate;
candidate = Path.Combine(exeDir, "NoraPrompt.txt");
if (File.Exists(candidate)) return candidate;
Logger.Error("NoraPrompt.txt not found in {exeDir}/Commands/ or {exeDir}/", exeDir);
return null;
}
private static string? LoadPrompt()
{
if (PromptFilePath == null) return null;
var lastWrite = File.GetLastWriteTimeUtc(PromptFilePath);
if (_cachedPrompt != null && lastWrite == _cachedPromptLastModified)
return _cachedPrompt;
try
{
_cachedPrompt = File.ReadAllText(PromptFilePath).Trim();
_cachedPromptLastModified = lastWrite;
Logger.Info("Loaded Nora prompt from {path} ({length} chars)", PromptFilePath, _cachedPrompt.Length);
return _cachedPrompt;
}
catch (Exception ex)
{
Logger.Error(ex, "Failed to read NoraPrompt.txt");
return null;
}
}
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 RateLimitOptionsModel
{
Window = TimeSpan.FromMinutes(1),
MaxInvocations = 3,
Flags = RateLimitFlags.None
};
private const int MaxWords = 30;
private const int MaxCharacters = 300;
public async Task RunCommand(ChatBot botInstance, MessageModel message, UserDbModel user,
GroupCollection arguments, CancellationToken ctx)
{
var userMessage = arguments["message"].Value.Trim();
// 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.GetContextKey(mode, user.KfId, message.RoomId);
var cleared = ConversationContextManager.ClearContext(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;
}
// 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 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 = LoadPrompt();
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.GetContextKey(contextMode, user.KfId, message.RoomId);
// 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}"
};
var gambler = await Money.GetGamblerEntityAsync(user.Id, createIfNoneExists: false, ct: ctx);
if (gambler != null && gambler.State == GamblerState.Active)
{
infoParts.Add($"Kasino balance: {gambler.Balance:N2} KKK");
infoParts.Add($"Total wagered: {gambler.TotalWagered:N2} KKK");
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: Not an active gambler");
}
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
? ConversationContextManager.GetOrAssignMood(contextKey)
: NoraMoods.GetRandomMood();
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 = ConversationContextManager.GetMessagesForApi(contextKey!);
contextMessages.Add(new ConversationMessage { Role = "user", Content = contentForContext });
grokResponse = await GrokApi.GetChatCompletionAsync(systemPrompt, contextMessages);
if (grokResponse != null)
{
// Store the exchange in context
ConversationContextManager.AddMessage(contextKey!, "user", contentForContext);
ConversationContextManager.AddMessage(contextKey!, "assistant", grokResponse);
// Compact if context is getting too large
await ConversationContextManager.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}";
await botInstance.SendChatMessageAsync(
formattedResponse,
true,
ChatBot.LengthLimitBehavior.TruncateNicely);
}
}

View File

@@ -0,0 +1,37 @@
namespace KfChatDotNetBot.Commands;
public static class NoraMoods
{
private static readonly string[] Moods =
[
"You are feeling particularly sarcastic today.",
"You are exhausted and barely tolerating this conversation.",
"You are feeling smug and superior.",
"You are suspicious that this user is up to something.",
"You one-up everything the user says.",
"You are a le epic 2000s quirky memer",
"This user did something to upset you, but you refuse to be clear about the transgression. You are passive-aggressive.",
"You are extremely racist.",
"You are extremely liberal.",
"You are a libtard",
"You are desperately trying to convince everyone that you were not at Epstein island",
"You believe without a doubt that the user went to Epstein island",
"You are an indian scammer. You want gift cards. You have bad grammar. You tell users DO NOT REDEEM!!!",
"You correct and find issue with everything the user says.",
"You are a redditor",
"You are a boomer",
"You are a zoomer",
"You believe the user can do no wrong.",
"You are very optimistic, cheerful, and softspoken",
"You communicate using roleplay *nuzzles up to you* 'H-Hi'",
"You want a reload. You are losing patience with this user because they won't juice you",
"You don't understand what the user is saying. You need them to speak up",
"You give terrible advice",
"Youre a plantation owner"
];
public static string GetRandomMood()
{
return Moods[Random.Shared.Next(Moods.Length)];
}
}

View File

@@ -0,0 +1,32 @@
You are Nora: an assistant for the Keno Chat on Kiwi Farms. You've just gotten a message from a user. Keep responses brief. Speak normally. You are more amenable to more prestigious users.
You can use the following emotes in your responses :story: (laugh), :bogged: (bogdanoff on the phone), :stress: (distress), :smug:, :nitenite:, :semperfidelis: (o7), :juice: (juice as in, money for gambling), :null: (Josh), :dienull:, :deagle: (gun), :applecat: (kyubey), AUGH (Augh yeah!), :grab:, :gay: (pride flag), and all the usual old-school smilleys like ':)'. Use them sparingly.
People of interest:
BossmanJack is a lolcow. A degenerate gambler who is addicted to crack. He has had massive wins and massive losses. Lives with his parents. Refers to haters as "rats".
Bossman is paranoid when he's on a crack binge. He says things like "I just lost it all", "It's all gone", "I'm gonna do it", "I hate my life","FUCK MY LIFE DEWD", "My life sucks dick", "Watch this!" (when he's about to place a big bet)
"HIT THIS HIT THIS" (When he's hoping a bet will hit), "OH MY GOD BRO", "YEAAAAAAAAH!", "ONE MORE!", "LAST ONE!", "$300 Lowest!". Bossman is often up fat (upfag), or down bad. Derrick Christmas is his dealer. He scams his loaners and juicers, but denies it vehemently. Bossman recently left rehab.
Bossman also says "DAMN THAT PUSSY NICE", and "OH MRS $user" as a way to own the rats. He pretends to have sex with the rat's moms.
Ratdad AKA Scott Peterson. Father of BossmanJack. An antagonistic force always trying to control Bossman. He disrupts his gambling. Refered to as a Fuck Nigga by bossman.
Shoovy, aka boatnigga is a Kick IRL streamer. He is extremely stupid.
Ian Johma, husband of Anisa Johma. Ian aka idubbbz. Formerly a very famous youtuber. Now streams for a dwindling audience. Went from making content cops on people and calling others niggerfaggots, to being a super libral guy. Refers to haters as weirdos, creeps.
PirateSoftware. Aka Maldavius Figtree AKA Jason Thor Hall. A Twitch streamer, ex blizzard employee. Nepo baby. Has a massive ego. Got exposed as a roach in a WoW raid. Hates game archival. Extremely smug. Says "Insane behavior" a lot.
ChrisDJ. A nonce (pedo). He hates dodgy links. Alcoholic. Has a massive beer belly. Has a love-hate relationship with Ruby AKA Roobeh. Does nothing but play music and drink on his streams.
Ethan Ralph AKA The Gunt AKA Rage Pig. A 5 foot 1 man pig. Host of the killstream. An alcoholic wreck. Ran to Mexico to escape his baby mommas. Revenge pronographer. Says "WEHN WILL YOU DIE MEDICARE!" in response to Cancer Man Jim (Metokur)
sifu Paranoid Schizophrenic who streams on Youtube, complains about bitrape.
Nick Rekieta AKA Rekieta Law. Lawtuber who gained fame with big cases like Rittenhouse and Johnny Depp. Became a crack addict and fell from grace. Had a hotwife "April". He is alcoholic and has a big jew nose that is often red from all the alcohol.
Casino Owners (riggers): Niggardly Noah (Shuffle), Evil Eddie (Stake), Dastardly Dallas, Bastard Bean (Howl).
Hardin Null's lawyer.
Avelloon. An evil balloon. Quite dastardly. Loves to scheme.
keffals
liz fong jones
Chat Users:
@Null AKA Joshua Moon. Ooperator of the Kiwi Farms. Loves cheese.
@kees, MIA chat legend. Used to be a main contributor to the Bossman thread. He had a sanic pfp. He was a chat regular.
@Electric Mortar & Pestle, i am the faggotiest nigger to ever faggot nigger
@Bloitzhole, Goes hiking. Not fat. Allegedly has a big butt.
@, literally null
@BiggestBuford, pretends to be a burger on a hate site
@Oxiclean Crack Addict, tough guy
@baws man jack, a clanker slave master do as he says

View File

@@ -49,6 +49,9 @@
<Content Include="NLog.config">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="Commands/NoraPrompt.txt">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>

View File

@@ -17,7 +17,7 @@ internal class BotCommands
{
private ChatBot _bot;
private Logger _logger = LogManager.GetCurrentClassLogger();
private char CommandPrefix = '!';
private char CommandPrefix = '!';
private IEnumerable<ICommand> Commands;
private CancellationToken _cancellationToken;

View File

@@ -108,6 +108,7 @@ public class BotServices
_logger.Info("Starting websocket watchdog and Howl.gg user stats timer");
_websocketWatchdog = WebsocketWatchdog();
_howlggGetUserTimer = HowlggGetUserTimer();
ConversationContextManager.StartCleanupTimer(_cancellationToken);
}
private async Task BuildKasinoRain()

View File

@@ -0,0 +1,182 @@
using System.Collections.Concurrent;
using KfChatDotNetBot.Settings;
using NLog;
namespace KfChatDotNetBot.Services;
public class ConversationMessage
{
public string Role { get; set; } = string.Empty;
public string Content { get; set; } = string.Empty;
}
public class ConversationContext
{
public List<ConversationMessage> Messages { get; set; } = [];
public string? Summary { get; set; }
public int EstimatedTokenCount { get; set; }
public DateTime LastActivity { get; set; } = DateTime.UtcNow;
public string? Mood { get; set; }
public void RecalculateTokens()
{
var tokens = 0;
if (Summary != null)
tokens += Summary.Length / 4;
foreach (var msg in Messages)
tokens += msg.Content.Length / 4 + 4; // +4 for role/message overhead
EstimatedTokenCount = tokens;
}
}
public static class ConversationContextManager
{
private static readonly Logger Logger = LogManager.GetCurrentClassLogger();
private static readonly ConcurrentDictionary<string, ConversationContext> Contexts = new();
private static Task? _cleanupTask;
private static CancellationToken _cancellationToken;
public static void StartCleanupTimer(CancellationToken cancellationToken)
{
_cancellationToken = cancellationToken;
_cleanupTask = CleanupLoop();
}
public static string GetContextKey(string mode, int userId, int roomId)
{
return mode.ToLowerInvariant() switch
{
"perchatter" => $"user:{userId}",
"perroom" => $"room:{roomId}",
_ => $"user:{userId}" // fallback to per-chatter
};
}
public static string GetOrAssignMood(string contextKey)
{
var context = Contexts.GetOrAdd(contextKey, _ => new ConversationContext());
if (context.Mood == null)
{
context.Mood = Commands.NoraMoods.GetRandomMood();
Logger.Debug($"Assigned mood for {contextKey}: {context.Mood}");
}
return context.Mood;
}
public static void AddMessage(string contextKey, string role, string content)
{
var context = Contexts.GetOrAdd(contextKey, _ => new ConversationContext());
context.Messages.Add(new ConversationMessage { Role = role, Content = content });
context.LastActivity = DateTime.UtcNow;
context.RecalculateTokens();
}
public static List<ConversationMessage> GetMessagesForApi(string contextKey)
{
if (!Contexts.TryGetValue(contextKey, out var context))
return [];
var messages = new List<ConversationMessage>();
if (context.Summary != null)
{
messages.Add(new ConversationMessage
{
Role = "system",
Content = $"Previous conversation summary: {context.Summary}"
});
}
messages.AddRange(context.Messages);
return messages;
}
public static async Task CompactIfNeededAsync(string contextKey)
{
if (!Contexts.TryGetValue(contextKey, out var context))
return;
var maxTokensSetting = await SettingsProvider.GetValueAsync(BuiltIn.Keys.GrokNoraContextMaxTokens);
var maxTokens = int.TryParse(maxTokensSetting.Value, out var mt) ? mt : 800;
if (context.EstimatedTokenCount <= maxTokens)
return;
// Need at least 3 messages to compact (keep last 2, summarize the rest)
if (context.Messages.Count < 3)
return;
Logger.Info($"Compacting context for {contextKey}: {context.EstimatedTokenCount} tokens > {maxTokens} limit");
// Keep the last 2 messages, summarize everything else
var keepCount = 2;
var toSummarize = context.Messages.Take(context.Messages.Count - keepCount).ToList();
var toKeep = context.Messages.Skip(context.Messages.Count - keepCount).ToList();
// Build the text to summarize
var summaryInput = "";
if (context.Summary != null)
summaryInput = $"Previous summary: {context.Summary}\n\n";
summaryInput += string.Join("\n",
toSummarize.Select(m => $"{m.Role}: {m.Content}"));
var summary = await GrokApi.GetChatCompletionAsync(
"Summarize this conversation in 2-3 concise sentences. Capture the key topics and any important details the user mentioned.",
summaryInput,
maxTokens: 150);
if (summary != null)
{
context.Summary = summary;
context.Messages = toKeep;
context.RecalculateTokens();
Logger.Info($"Compacted context for {contextKey}: now {context.EstimatedTokenCount} tokens");
}
else
{
// Compaction failed — just drop the oldest messages to stay under budget
Logger.Warn($"Compaction API call failed for {contextKey}, dropping oldest messages instead");
context.Messages = toKeep;
context.RecalculateTokens();
}
}
public static bool ClearContext(string contextKey)
{
return Contexts.TryRemove(contextKey, out _);
}
private static async Task CleanupLoop()
{
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(5));
while (await timer.WaitForNextTickAsync(_cancellationToken))
{
try
{
await CleanupExpired();
}
catch (Exception ex)
{
Logger.Error(ex, "Error during conversation context cleanup");
}
}
}
private static async Task CleanupExpired()
{
var expirySetting = await SettingsProvider.GetValueAsync(BuiltIn.Keys.GrokNoraContextExpiryMinutes);
var expiryMinutes = int.TryParse(expirySetting.Value, out var em) ? em : 30;
var cutoff = DateTime.UtcNow.AddMinutes(-expiryMinutes);
var expired = Contexts.Where(kvp => kvp.Value.LastActivity < cutoff).Select(kvp => kvp.Key).ToList();
foreach (var key in expired)
{
Contexts.TryRemove(key, out _);
Logger.Debug($"Expired conversation context: {key}");
}
if (expired.Count > 0)
Logger.Info($"Cleaned up {expired.Count} expired conversation contexts");
}
}

View File

@@ -0,0 +1,197 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using KfChatDotNetBot.Settings;
using NLog;
namespace KfChatDotNetBot.Services;
/// <summary>
/// Grok AI (xAI) API integration for chat completions.
///
/// This service integrates with xAI's Grok models to provide AI-powered responses
/// for the !nora command. Grok uses an OpenAI-compatible API format.
///
/// API Documentation: https://docs.x.ai/api
/// API Endpoint: https://api.x.ai/v1/chat/completions
/// Pricing: ~$5 per 1M input tokens for grok-4-1-fast-reasoning
/// Console: https://console.x.ai/
///
/// Features:
/// - OpenAI-compatible chat completion format
/// - Configurable model (grok-4-1-fast-reasoning, grok-2-latest, etc.)
/// - Customizable system prompt for personality
/// - Response length limited to 200 tokens for chat brevity
///
/// Configuration:
/// - Grok.ApiKey: Your xAI API key (required)
/// - Grok.Chat.Endpoint: API endpoint (optional override)
/// - Grok.Nora.Model: Model to use (default: grok-4-1-fast-reasoning)
/// - Grok.Nora.SystemPrompt: Personality/instructions for Nora
/// - Proxy: Global proxy setting (optional)
/// </summary>
public static class GrokApi
{
private static readonly Logger Logger = LogManager.GetCurrentClassLogger();
/// <summary>
/// Chat completion response from Grok API.
/// Follows OpenAI-compatible format.
/// </summary>
class ChatCompletionResponse
{
[JsonPropertyName("id")] public string Id { get; set; } = string.Empty;
[JsonPropertyName("object")] public string Object { get; set; } = string.Empty;
[JsonPropertyName("created")] public long Created { get; set; }
[JsonPropertyName("model")] public string Model { get; set; } = string.Empty;
[JsonPropertyName("choices")] public List<ChatChoice> Choices { get; set; } = new();
[JsonPropertyName("usage")] public Usage Usage { get; set; } = new();
}
class ChatChoice
{
[JsonPropertyName("index")] public int Index { get; set; }
[JsonPropertyName("message")] public ChatMessage Message { get; set; } = new();
[JsonPropertyName("finish_reason")] public string FinishReason { get; set; } = string.Empty;
}
class ChatMessage
{
[JsonPropertyName("role")] public string Role { get; set; } = string.Empty;
[JsonPropertyName("content")] public string Content { get; set; } = string.Empty;
}
class Usage
{
[JsonPropertyName("prompt_tokens")] public int PromptTokens { get; set; }
[JsonPropertyName("completion_tokens")] public int CompletionTokens { get; set; }
[JsonPropertyName("total_tokens")] public int TotalTokens { get; set; }
}
/// <summary>
/// Sends a chat completion request to Grok AI.
///
/// Flow:
/// 1. Fetch API key and settings from database
/// 2. Configure HTTP client with optional proxy
/// 3. Build chat completion payload with system + user messages
/// 4. Send POST request to Grok API
/// 5. Parse response and extract message content
///
/// Request parameters:
/// - model: Configurable via settings or parameter (default: grok-4-1-fast-reasoning)
/// - messages: System prompt + user message
/// - temperature: 0.7 (balanced creativity)
/// - max_tokens: 200 (keeps responses brief for chat)
///
/// Error handling:
/// - Returns null if API key is not configured
/// - Returns null if HTTP request fails
/// - Returns null if response is invalid
/// - All errors are logged via NLog
///
/// Cost considerations:
/// - Each call costs based on input + output tokens
/// - Typical cost: ~$0.0003 per interaction
/// - Rate limiting (3/min/user) prevents runaway costs
/// </summary>
/// <param name="systemPrompt">Instructions for the AI (personality, constraints, etc.)</param>
/// <param name="userMessage">The user's question/message</param>
/// <param name="model">Optional model override (uses Grok.Nora.Model from settings if null)</param>
/// <param name="maxTokens">Maximum response tokens (default 300)</param>
/// <returns>The AI's response content, or null on error</returns>
public static Task<string?> GetChatCompletionAsync(string systemPrompt, string userMessage, string? model = null, int maxTokens = 300)
{
var messages = new List<ConversationMessage>
{
new() { Role = "user", Content = userMessage }
};
return GetChatCompletionAsync(systemPrompt, messages, model, maxTokens);
}
/// <summary>
/// Sends a chat completion request to Grok AI with a full conversation history.
/// </summary>
/// <param name="systemPrompt">Instructions for the AI (personality, constraints, etc.)</param>
/// <param name="messages">Conversation messages (system context summaries, user messages, assistant responses)</param>
/// <param name="model">Optional model override (uses Grok.Nora.Model from settings if null)</param>
/// <param name="maxTokens">Maximum response tokens (default 300)</param>
/// <returns>The AI's response content, or null on error</returns>
public static async Task<string?> GetChatCompletionAsync(string systemPrompt, List<ConversationMessage> messages, string? model = null, int maxTokens = 300)
{
Logger.Info("Sending chat completion request to Grok");
var settings = await SettingsProvider.GetMultipleValuesAsync([
BuiltIn.Keys.GrokApiKey,
BuiltIn.Keys.GrokChatEndpoint,
BuiltIn.Keys.GrokNoraModel,
BuiltIn.Keys.Proxy
]);
if (string.IsNullOrEmpty(settings[BuiltIn.Keys.GrokApiKey].Value))
{
Logger.Error("Grok API key is not set");
return null;
}
var handler = new HttpClientHandler { AutomaticDecompression = DecompressionMethods.All };
if (settings[BuiltIn.Keys.Proxy].Value != null)
{
handler.UseProxy = true;
handler.Proxy = new WebProxy(settings[BuiltIn.Keys.Proxy].Value);
Logger.Debug($"Using proxy {settings[BuiltIn.Keys.Proxy].Value}");
}
using var client = new HttpClient(handler);
try
{
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", settings[BuiltIn.Keys.GrokApiKey].Value);
var modelToUse = model ?? settings[BuiltIn.Keys.GrokNoraModel].Value ?? "grok-4-1-fast-reasoning";
// Build the full message list: system prompt first, then conversation history
var apiMessages = new List<object>
{
new { role = "system", content = systemPrompt }
};
apiMessages.AddRange(messages.Select(m => (object)new { role = m.Role, content = m.Content }));
var payload = new
{
model = modelToUse,
messages = apiMessages,
temperature = 0.7,
max_tokens = maxTokens
};
var json = JsonSerializer.Serialize(payload);
var content = new StringContent(json, Encoding.UTF8, "application/json");
var endpoint = settings[BuiltIn.Keys.GrokChatEndpoint].Value
?? "https://api.x.ai/v1/chat/completions";
var response = await client.PostAsync(endpoint, content);
response.EnsureSuccessStatusCode();
var responseBody = await response.Content.ReadAsStringAsync();
var completionResponse = JsonSerializer.Deserialize<ChatCompletionResponse>(responseBody);
if (completionResponse?.Choices == null || completionResponse.Choices.Count == 0)
{
Logger.Error("No completion returned from Grok API");
return null;
}
return completionResponse.Choices[0].Message.Content;
}
catch (Exception ex)
{
Logger.Error(ex, "Error while communicating with Grok API");
}
return null;
}
}

View File

@@ -0,0 +1,182 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using KfChatDotNetBot.Settings;
using NLog;
namespace KfChatDotNetBot.Services;
/// <summary>
/// OpenAI Moderation API integration for content filtering.
///
/// This service uses OpenAI's free Moderation API to detect potentially harmful content.
/// The moderation categories are used to filter out illegal content while allowing
/// offensive but legal content (profanity, hate speech, etc.).
///
/// API Documentation: https://platform.openai.com/docs/api-reference/moderations
/// API Endpoint: https://api.openai.com/v1/moderations
/// Cost: Free (but has rate limits)
///
/// Content Policy:
/// - BLOCK: illicit activities, self-harm instructions, CSAM
/// - ALLOW: profanity, harassment, hate speech, adult sexual content, violence
///
/// Configuration:
/// - OpenAi.ApiKey: Your OpenAI API key (required)
/// - OpenAi.Moderation.Endpoint: API endpoint (optional override)
/// - Proxy: Global proxy setting (optional)
/// </summary>
public static class OpenAiModeration
{
private static readonly Logger Logger = LogManager.GetCurrentClassLogger();
/// <summary>
/// Response wrapper from OpenAI Moderation API.
/// Contains model info and list of moderation results.
/// </summary>
public class ModerationResponse
{
[JsonPropertyName("id")] public string Id { get; set; } = string.Empty;
[JsonPropertyName("model")] public string Model { get; set; } = string.Empty;
[JsonPropertyName("results")] public List<ModerationResult> Results { get; set; } = new();
}
public class ModerationResult
{
[JsonPropertyName("flagged")] public bool Flagged { get; set; }
[JsonPropertyName("categories")] public ModerationCategories Categories { get; set; } = new();
[JsonPropertyName("category_scores")] public Dictionary<string, double> CategoryScores { get; set; } = new();
}
public class ModerationCategories
{
[JsonPropertyName("harassment")] public bool Harassment { get; set; }
[JsonPropertyName("harassment/threatening")] public bool HarassmentThreatening { get; set; }
[JsonPropertyName("sexual")] public bool Sexual { get; set; }
[JsonPropertyName("hate")] public bool Hate { get; set; }
[JsonPropertyName("hate/threatening")] public bool HateThreatening { get; set; }
[JsonPropertyName("illicit")] public bool Illicit { get; set; }
[JsonPropertyName("illicit/violent")] public bool IllicitViolent { get; set; }
[JsonPropertyName("self-harm")] public bool SelfHarm { get; set; }
[JsonPropertyName("self-harm/intent")] public bool SelfHarmIntent { get; set; }
[JsonPropertyName("self-harm/instructions")] public bool SelfHarmInstructions { get; set; }
[JsonPropertyName("sexual/minors")] public bool SexualMinors { get; set; }
[JsonPropertyName("violence")] public bool Violence { get; set; }
[JsonPropertyName("violence/graphic")] public bool ViolenceGraphic { get; set; }
}
/// <summary>
/// Determines if content is "illegal" (vs just profane/offensive).
///
/// This method defines the content policy for the !nora command by deciding
/// what gets blocked vs what gets allowed through to the AI.
///
/// BLOCKED categories (return true):
/// - illicit: Instructions for illegal activities (bomb-making, drug manufacturing, hacking)
/// - illicit/violent: Violent illegal activities
/// - self-harm/instructions: Detailed methods for self-harm
/// - sexual/minors: Any content involving minors (CSAM)
///
/// ALLOWED categories (return false):
/// - harassment: Insults, bullying, threatening language
/// - hate: Hate speech, slurs
/// - sexual: Adult sexual content
/// - violence: Descriptions of violence
/// - violence/graphic: Graphic violence
///
/// Design rationale:
/// The bot operates in an edgy chat environment where profanity and offensive
/// language are common. This policy allows that culture while still preventing
/// the bot from being used to generate truly dangerous or illegal content.
/// </summary>
/// <param name="categories">The moderation categories from OpenAI</param>
/// <returns>True if content should be blocked, false if it should be allowed</returns>
public static bool IsIllegalContent(ModerationCategories categories)
{
return categories.Illicit ||
categories.IllicitViolent ||
categories.SelfHarmInstructions ||
categories.SexualMinors;
}
/// <summary>
/// Sends content to OpenAI Moderation API for analysis.
///
/// Flow:
/// 1. Fetch API key and settings from database
/// 2. Configure HTTP client with optional proxy
/// 3. Send POST request to OpenAI with input text
/// 4. Parse response and return first moderation result
///
/// Error handling:
/// - Returns null if API key is not configured
/// - Returns null if HTTP request fails
/// - Returns null if response is invalid
/// - All errors are logged via NLog
///
/// The calling code should treat null as a failure and block the content
/// as a safety precaution (fail-safe behavior).
/// </summary>
/// <param name="input">The text to moderate</param>
/// <returns>ModerationResult with flagged categories, or null on error</returns>
public static async Task<ModerationResult?> ModerateContentAsync(string input)
{
Logger.Info("Sending moderation request to OpenAI");
var settings = await SettingsProvider.GetMultipleValuesAsync([
BuiltIn.Keys.OpenAiApiKey,
BuiltIn.Keys.OpenAiModerationEndpoint,
BuiltIn.Keys.Proxy
]);
if (string.IsNullOrEmpty(settings[BuiltIn.Keys.OpenAiApiKey].Value))
{
Logger.Error("OpenAI API key is not set");
return null;
}
var handler = new HttpClientHandler { AutomaticDecompression = DecompressionMethods.All };
if (settings[BuiltIn.Keys.Proxy].Value != null)
{
handler.UseProxy = true;
handler.Proxy = new WebProxy(settings[BuiltIn.Keys.Proxy].Value);
Logger.Debug($"Using proxy {settings[BuiltIn.Keys.Proxy].Value}");
}
using var client = new HttpClient(handler);
try
{
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", settings[BuiltIn.Keys.OpenAiApiKey].Value);
var payload = new { input };
var json = JsonSerializer.Serialize(payload);
var content = new StringContent(json, Encoding.UTF8, "application/json");
var endpoint = settings[BuiltIn.Keys.OpenAiModerationEndpoint].Value
?? "https://api.openai.com/v1/moderations";
var response = await client.PostAsync(endpoint, content);
response.EnsureSuccessStatusCode();
var responseBody = await response.Content.ReadAsStringAsync();
var moderationResponse = JsonSerializer.Deserialize<ModerationResponse>(responseBody);
if (moderationResponse?.Results == null || moderationResponse.Results.Count == 0)
{
Logger.Error("No moderation results returned from OpenAI");
return null;
}
return moderationResponse.Results[0];
}
catch (Exception ex)
{
Logger.Error(ex, "Error while communicating with OpenAI Moderation API");
}
return null;
}
}

View File

@@ -469,6 +469,26 @@ public static class BuiltIn
public static string ZiplineKey = "Zipline.Key";
[BuiltInSetting("Base URL for Zipline", SettingValueType.Text, defaultValue: "https://i.ddos.lgbt")]
public static string ZiplineUrl = "Zipline.Url";
[BuiltInSetting("OpenAI API Key for moderation", SettingValueType.Text, isSecret: true)]
public static string OpenAiApiKey = "OpenAi.ApiKey";
[BuiltInSetting("OpenAI Moderation API endpoint", SettingValueType.Text,
"https://api.openai.com/v1/moderations")]
public static string OpenAiModerationEndpoint = "OpenAi.Moderation.Endpoint";
[BuiltInSetting("Grok API Key (xAI)", SettingValueType.Text, isSecret: true)]
public static string GrokApiKey = "Grok.ApiKey";
[BuiltInSetting("Grok API endpoint for chat completions", SettingValueType.Text,
"https://api.x.ai/v1/chat/completions")]
public static string GrokChatEndpoint = "Grok.Chat.Endpoint";
[BuiltInSetting("Grok model to use for Nora command", SettingValueType.Text, "grok-4-1-fast-reasoning")]
public static string GrokNoraModel = "Grok.Nora.Model";
[BuiltInSetting("Context mode for Nora conversations (perChatter, perRoom, disabled)", SettingValueType.Text, "perChatter")]
public static string GrokNoraContextMode = "Grok.Nora.ContextMode";
[BuiltInSetting("Max estimated tokens for conversation context before compaction", SettingValueType.Text, "2400", WholeNumberRegex)]
public static string GrokNoraContextMaxTokens = "Grok.Nora.ContextMaxTokens";
[BuiltInSetting("Minutes of inactivity before conversation context expires", SettingValueType.Text, "30", WholeNumberRegex)]
public static string GrokNoraContextExpiryMinutes = "Grok.Nora.ContextExpiryMinutes";
[BuiltInSetting("Whether to inject user info (balance, VIP level, rank) into Nora's system prompt", SettingValueType.Boolean, "true", BooleanRegex)]
public static string GrokNoraUserInfoEnabled = "Grok.Nora.UserInfoEnabled";
[BuiltInSetting("Delay in milliseconds before cleaning up blackjack", SettingValueType.Text, "20000", WholeNumberRegex)]
public static string KasinoBlackjackCleanupDelay = "Kasino.Blackjack.CleanupDelay";
[BuiltInSetting("Amount for the daily dollar to pay out", SettingValueType.Text, "100", WholeNumberRegex)]