From 30d9f48d2ea0e7135d0a182ffb35ef43b7aaf507 Mon Sep 17 00:00:00 2001 From: xXCryingLaughingXx <253611749+xXCryingLaughingXx@users.noreply.github.com> Date: Tue, 17 Feb 2026 20:24:30 -0600 Subject: [PATCH] Nora (#87) --- CLAUDE.md | 247 ++++++++++ KfChatDotNetBot/Commands/NoraCommand.cs | 302 ++++++++++++ KfChatDotNetBot/Commands/NoraMoods.cs | 37 ++ KfChatDotNetBot/Commands/NoraPrompt.txt | 32 ++ KfChatDotNetBot/KfChatDotNetBot.csproj | 3 + KfChatDotNetBot/Services/BotCommands.cs | 2 +- KfChatDotNetBot/Services/BotServices.cs | 1 + .../Services/ConversationContextManager.cs | 182 +++++++ KfChatDotNetBot/Services/GrokApi.cs | 197 ++++++++ KfChatDotNetBot/Services/OpenAiModeration.cs | 182 +++++++ KfChatDotNetBot/Settings/BuiltIn.cs | 20 + NORA_SETUP.md | 465 ++++++++++++++++++ PRIVACY.md | 96 ++++ README.md | 293 +++++++++++ 14 files changed, 2058 insertions(+), 1 deletion(-) create mode 100644 CLAUDE.md create mode 100644 KfChatDotNetBot/Commands/NoraCommand.cs create mode 100644 KfChatDotNetBot/Commands/NoraMoods.cs create mode 100644 KfChatDotNetBot/Commands/NoraPrompt.txt create mode 100644 KfChatDotNetBot/Services/ConversationContextManager.cs create mode 100644 KfChatDotNetBot/Services/GrokApi.cs create mode 100644 KfChatDotNetBot/Services/OpenAiModeration.cs create mode 100644 NORA_SETUP.md create mode 100644 PRIVACY.md create mode 100644 README.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ae82f02 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,247 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +KfChatDotNet is a C# .NET 10.0 chat bot for KiwiFarms (Sneedchat) with extensive third-party integrations. The solution consists of three projects: + +- **KfChatDotNetWsClient**: WebSocket client library for KiwiFarms chat communication +- **KickWsClient**: WebSocket client library for Kick platform integration +- **KfChatDotNetBot**: Main bot application with command handling and service integrations + +## Build and Development Commands + +```bash +# Build the entire solution +dotnet build + +# Build in Release mode +dotnet build -c Release + +# Run the bot +dotnet run --project KfChatDotNetBot + +# Clean build artifacts +dotnet clean + +# Restore NuGet packages +dotnet restore +``` + +## Database Management + +The bot uses Entity Framework Core with SQLite (`db.sqlite`). + +```bash +# Add a new migration (from solution root) +dotnet ef migrations add --project KfChatDotNetBot + +# Update database to latest migration +dotnet ef database update --project KfChatDotNetBot + +# Remove the last migration +dotnet ef migrations remove --project KfChatDotNetBot + +# View migration SQL +dotnet ef migrations script --project KfChatDotNetBot +``` + +Migrations run automatically on startup via `Program.cs`. + +## Architecture + +### Message Flow + +1. **ChatClient** ([KfChatDotNetWsClient/ChatClient.cs](KfChatDotNetWsClient/ChatClient.cs)): WebSocket connection and event emission + - Connects to KiwiFarms WebSocket endpoint + - Parses incoming packets and emits typed events + - Handles reconnection logic and cookie management + +2. **ChatBot** ([KfChatDotNetBot/ChatBot.cs](KfChatDotNetBot/ChatBot.cs)): Core bot orchestration + - Subscribes to ChatClient events + - Manages sent message tracking and auto-deletion + - Handles GambaSesh presence detection + - Coordinates with BotServices for external integrations + +3. **BotCommands** ([KfChatDotNetBot/Services/BotCommands.cs](KfChatDotNetBot/Services/BotCommands.cs)): Command routing + - Uses reflection to discover all ICommand implementations + - Matches incoming messages against command regex patterns + - Enforces rate limits, permissions, and timeouts + - Filters based on user rights and Kasino bans/exclusions + +4. **ICommand Implementations** ([KfChatDotNetBot/Commands/](KfChatDotNetBot/Commands/)): Individual command handlers + - Each command defines regex patterns for matching + - Commands specify required user rights and rate limits + - Commands can be marked with attributes (NoPrefixRequired, KasinoCommand, WagerCommand, AllowAdditionalMatches) + +### Command System + +All commands implement `ICommand` interface: +- `Patterns`: List of regex patterns for command matching +- `HelpText`: Help text shown to users (null to hide from help) +- `RequiredRight`: Minimum user permission level +- `Timeout`: Command execution timeout +- `RateLimitOptions`: Rate limiting configuration +- `RunCommand()`: Async method that executes the command + +Command attributes: +- `[NoPrefixRequired]`: Command matches without requiring `!` prefix +- `[AllowAdditionalMatches]`: Continue processing after this command matches +- `[KasinoCommand]`: Requires Kasino to be enabled, enforces bans +- `[WagerCommand]`: Enforces self-exclusions for gambling addicts + +### Settings System + +Settings are stored in the database (not config files) via `SettingsProvider`: +- `SettingsProvider.GetValueAsync(key)` - Get a single setting +- `SettingsProvider.GetMultipleValuesAsync(keys)` - Get multiple settings efficiently +- `SettingsProvider.SetValueAsync(key, value)` - Update a setting +- Built-in keys defined in `BuiltIn.Keys` static class +- Migration from legacy `config.json` happens automatically on startup + +### Service Integrations + +**BotServices** ([KfChatDotNetBot/Services/BotServices.cs](KfChatDotNetBot/Services/BotServices.cs)) initializes and manages connections to external services: +- Discord: Message relaying and bot presence +- Twitch: Stream status monitoring and GraphQL API +- Kick: WebSocket connection for stream events +- Gambling sites: Rainbet, Shuffle, Howlgg, Chipsgg, Clashgg, BetBolt, Yeet +- Stream platforms: DLive, PeerTube, Owncast, YouTube +- Kasino: Internal gambling system with rain, mines, limbo, coinflip + +Each service is implemented as a separate class in [KfChatDotNetBot/Services/](KfChatDotNetBot/Services/). + +### Message Tracking + +`ChatBot.SendChatMessage()` and `ChatBot.SendChatMessageAsync()` return a `SentMessageTrackerModel`: +- Tracks message status (WaitingForResponse, ResponseReceived, Lost, NotSending) +- Provides message ID for editing/deletion +- Measures round-trip delay +- Supports auto-deletion after a specified TimeSpan +- Handles message replay after disconnection + +### Database Schema + +The `ApplicationDbContext` manages these entities: +- `Users`: KiwiFarms users with permissions +- `Gamblers`: Kasino users with balance and stats +- `Transactions`: Kasino transaction history +- `Wagers`: Active and historical bets +- `Exclusions`: Self-exclusion periods +- `Perks`: Gambler perks (e.g., reduced house edge) +- `Settings`: Bot configuration +- `Images`: Uploaded image metadata +- `UsersWhoWere`: User activity timestamps (join/part/message) +- Various third-party service data tables (HowlggBets, RainbetBets, etc.) + +## Important Patterns + +### Async/Await Conventions + +The codebase has inconsistent async patterns: +- Some methods expose both sync and async versions (e.g., `SendChatMessage()` and `SendChatMessageAsync()`) +- `Disconnect()` is intentionally synchronous with a separate `DisconnectAsync()` method +- Avoid changing existing sync/async signatures without careful consideration + +### GambaSesh Detection + +GambaSesh is another bot that the bot avoids conflicting with: +- `ChatBot.GambaSeshPresent` tracks his presence +- Messages are suppressed by default when he's present (unless `bypassSeshDetect=true`) +- Presence is detected via user join events and messages +- `BotServices.TemporarilyBypassGambaSeshForDiscord` exists for special cases + +### Length Limits + +Sneedchat enforces a 1023-byte message limit: +- `SendChatMessage()` accepts `LengthLimitBehavior` enum: TruncateNicely, TruncateExactly, RefuseToSend, DoNothing +- Use `string.Utf8LengthBytes()` extension method to check length +- Use `string.TruncateBytes(limit)` extension method to truncate safely + +### Session Management + +The bot handles KiwiFarms authentication via cookies: +- `KfTokenService` manages login and cookie refresh +- On `203` status code or "cannot join room" errors, cookies are refreshed +- Cookies are persisted to the database +- Bot can operate in "guest mode" with no cookies + +## Common Tasks + +### Adding a New Command + +1. Create a new class in `KfChatDotNetBot/Commands/` that implements `ICommand` +2. Define regex patterns in the `Patterns` property +3. Implement `RunCommand()` method with command logic +4. Set `RequiredRight`, `Timeout`, `RateLimitOptions`, and `HelpText` +5. Add attributes if needed: `[NoPrefixRequired]`, `[KasinoCommand]`, etc. +6. The command will be auto-discovered via reflection on next startup + +### Adding a New Service Integration + +1. Create a new class in `KfChatDotNetBot/Services/` +2. Add initialization method to `BotServices.InitializeServices()` +3. Add corresponding settings keys to `BuiltIn.Keys` +4. If the service needs WebSocket or periodic tasks, follow existing patterns (PeriodicTimer, WebsocketClient) + +### Modifying the Database Schema + +1. Update the relevant `DbModel` class in `KfChatDotNetBot/Models/DbModels/` +2. Add/update the `DbSet` property in `ApplicationDbContext.cs` +3. Generate a migration: `dotnet ef migrations add --project KfChatDotNetBot` +4. Migration runs automatically on next bot startup + +### Sending Messages to Chat + +```csharp +// Synchronous +var tracker = _bot.SendChatMessage("Hello!"); + +// Asynchronous +var tracker = await _bot.SendChatMessageAsync("Hello!"); + +// Bypass GambaSesh detection +_bot.SendChatMessage("Important message", bypassSeshDetect: true); + +// Auto-delete after 5 seconds +await _bot.SendChatMessageAsync("Temporary message", autoDeleteAfter: TimeSpan.FromSeconds(5)); + +// Wait for the message to be echoed by the server +if (await _bot.WaitForChatMessageAsync(tracker, TimeSpan.FromSeconds(10))) +{ + // Message was successfully sent, tracker.ChatMessageId is now available +} +``` + +## Dependencies + +Key NuGet packages: +- `Websocket.Client`: WebSocket client used throughout +- `Microsoft.EntityFrameworkCore.Sqlite`: Database ORM +- `NLog`: Logging framework +- `Humanizer.Core`: Human-readable text formatting +- `SixLabors.ImageSharp`: Image manipulation for meme generation +- `StackExchange.Redis`: Redis caching +- `FlareSolverrSharp`: Cloudflare bypass +- `HtmlAgilityPack`: HTML parsing +- `Nerdbank.GitVersioning`: Automatic versioning from git tags + +## Testing + +There are currently no automated tests in this repository. Manual testing is performed by running the bot. + +## Logging + +NLog configuration is in `KfChatDotNetBot/NLog.config`. The bot logs extensively: +- Debug: Detailed packet information, message processing steps +- Info: User joins/parts, sent messages, state changes +- Error: Exceptions, disconnections, failed operations + +## Code Style Notes + +- The codebase uses explicit null checks and nullable reference types +- Extensive use of LINQ for data queries +- Some deliberately provocative comments and language (see Program.cs license header) +- Privacy is explicitly not a concern - data is logged freely +- Performance optimizations include "BUY MORE RAM" philosophy (unlimited message tracking) diff --git a/KfChatDotNetBot/Commands/NoraCommand.cs b/KfChatDotNetBot/Commands/NoraCommand.cs new file mode 100644 index 0000000..c0290fc --- /dev/null +++ b/KfChatDotNetBot/Commands/NoraCommand.cs @@ -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; + +/// +/// 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. +/// +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 Patterns => [ + new Regex(@"^nora\s+(?.+)", 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 + { + $"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); + } +} diff --git a/KfChatDotNetBot/Commands/NoraMoods.cs b/KfChatDotNetBot/Commands/NoraMoods.cs new file mode 100644 index 0000000..07ecfde --- /dev/null +++ b/KfChatDotNetBot/Commands/NoraMoods.cs @@ -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)]; + } +} diff --git a/KfChatDotNetBot/Commands/NoraPrompt.txt b/KfChatDotNetBot/Commands/NoraPrompt.txt new file mode 100644 index 0000000..fbd7b6e --- /dev/null +++ b/KfChatDotNetBot/Commands/NoraPrompt.txt @@ -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 \ No newline at end of file diff --git a/KfChatDotNetBot/KfChatDotNetBot.csproj b/KfChatDotNetBot/KfChatDotNetBot.csproj index 8999b8b..c8194fe 100644 --- a/KfChatDotNetBot/KfChatDotNetBot.csproj +++ b/KfChatDotNetBot/KfChatDotNetBot.csproj @@ -49,6 +49,9 @@ PreserveNewest + + PreserveNewest + diff --git a/KfChatDotNetBot/Services/BotCommands.cs b/KfChatDotNetBot/Services/BotCommands.cs index e7a262b..f1b1e9e 100644 --- a/KfChatDotNetBot/Services/BotCommands.cs +++ b/KfChatDotNetBot/Services/BotCommands.cs @@ -17,7 +17,7 @@ internal class BotCommands { private ChatBot _bot; private Logger _logger = LogManager.GetCurrentClassLogger(); - private char CommandPrefix = '!'; + private char CommandPrefix = '!'; private IEnumerable Commands; private CancellationToken _cancellationToken; diff --git a/KfChatDotNetBot/Services/BotServices.cs b/KfChatDotNetBot/Services/BotServices.cs index bf56e28..b3a1a00 100644 --- a/KfChatDotNetBot/Services/BotServices.cs +++ b/KfChatDotNetBot/Services/BotServices.cs @@ -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() diff --git a/KfChatDotNetBot/Services/ConversationContextManager.cs b/KfChatDotNetBot/Services/ConversationContextManager.cs new file mode 100644 index 0000000..bcfb0bc --- /dev/null +++ b/KfChatDotNetBot/Services/ConversationContextManager.cs @@ -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 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 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 GetMessagesForApi(string contextKey) + { + if (!Contexts.TryGetValue(contextKey, out var context)) + return []; + + var messages = new List(); + + 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"); + } +} diff --git a/KfChatDotNetBot/Services/GrokApi.cs b/KfChatDotNetBot/Services/GrokApi.cs new file mode 100644 index 0000000..c8b81db --- /dev/null +++ b/KfChatDotNetBot/Services/GrokApi.cs @@ -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; + +/// +/// 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) +/// +public static class GrokApi +{ + private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); + + /// + /// Chat completion response from Grok API. + /// Follows OpenAI-compatible format. + /// + 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 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; } + } + + /// + /// 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 + /// + /// Instructions for the AI (personality, constraints, etc.) + /// The user's question/message + /// Optional model override (uses Grok.Nora.Model from settings if null) + /// Maximum response tokens (default 300) + /// The AI's response content, or null on error + public static Task GetChatCompletionAsync(string systemPrompt, string userMessage, string? model = null, int maxTokens = 300) + { + var messages = new List + { + new() { Role = "user", Content = userMessage } + }; + return GetChatCompletionAsync(systemPrompt, messages, model, maxTokens); + } + + /// + /// Sends a chat completion request to Grok AI with a full conversation history. + /// + /// Instructions for the AI (personality, constraints, etc.) + /// Conversation messages (system context summaries, user messages, assistant responses) + /// Optional model override (uses Grok.Nora.Model from settings if null) + /// Maximum response tokens (default 300) + /// The AI's response content, or null on error + public static async Task GetChatCompletionAsync(string systemPrompt, List 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 + { + 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(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; + } +} diff --git a/KfChatDotNetBot/Services/OpenAiModeration.cs b/KfChatDotNetBot/Services/OpenAiModeration.cs new file mode 100644 index 0000000..d1406d0 --- /dev/null +++ b/KfChatDotNetBot/Services/OpenAiModeration.cs @@ -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; + +/// +/// 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) +/// +public static class OpenAiModeration +{ + private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); + + /// + /// Response wrapper from OpenAI Moderation API. + /// Contains model info and list of moderation results. + /// + 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 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 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; } + } + + /// + /// 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. + /// + /// The moderation categories from OpenAI + /// True if content should be blocked, false if it should be allowed + public static bool IsIllegalContent(ModerationCategories categories) + { + return categories.Illicit || + categories.IllicitViolent || + categories.SelfHarmInstructions || + categories.SexualMinors; + } + + /// + /// 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). + /// + /// The text to moderate + /// ModerationResult with flagged categories, or null on error + public static async Task 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(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; + } +} diff --git a/KfChatDotNetBot/Settings/BuiltIn.cs b/KfChatDotNetBot/Settings/BuiltIn.cs index 0043dda..55b76b4 100644 --- a/KfChatDotNetBot/Settings/BuiltIn.cs +++ b/KfChatDotNetBot/Settings/BuiltIn.cs @@ -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)] diff --git a/NORA_SETUP.md b/NORA_SETUP.md new file mode 100644 index 0000000..51e1ee6 --- /dev/null +++ b/NORA_SETUP.md @@ -0,0 +1,465 @@ +# Nora AI Command Setup Guide + +## Table of Contents + +- [Quick Reference: Nora Variables](#quick-reference-nora-variables) +- [Overview](#overview) +- [Features](#features) +- [Prerequisites](#prerequisites) +- [Initial Setup](#initial-setup) +- [Configuration Options](#configuration-options) +- [How It Works](#how-it-works) +- [Content Moderation Details](#content-moderation-details) +- [Conversation Context](#conversation-context) +- [Testing the Command](#testing-the-command) +- [Troubleshooting](#troubleshooting) +- [Cost Monitoring](#cost-monitoring) +- [Security Considerations](#security-considerations) +- [Advanced Configuration](#advanced-configuration) +- [Code Architecture](#code-architecture) +- [FAQ](#faq) + +## Quick Reference: Nora Variables + +All configurable knobs for `!nora`, with direct links to where they're defined. + +### Files + +| Variable | File | Line(s) | Default | Description | +|----------|------|---------|---------|-------------| +| System prompt | [NoraPrompt.txt](KfChatDotNetBot/Commands/NoraPrompt.txt) | entire file | *(see file)* | Nora's personality, context, and instructions. Edit this file freely — changes are picked up on next invocation without restart. | +| Moods | [NoraMoods.cs:5-17](KfChatDotNetBot/Commands/NoraMoods.cs#L5-L17) | 5-17 | 10 moods | Random mood injected per conversation context | +| Rate limit | [NoraCommand.cs:89-94](KfChatDotNetBot/Commands/NoraCommand.cs#L89-L94) | 89-94 | 1/min per user | `Window`, `MaxInvocations`, `Flags` | +| Input word limit | [NoraCommand.cs:96](KfChatDotNetBot/Commands/NoraCommand.cs#L96) | 96 | 15 words | `MaxWords` constant | +| Input char limit | [NoraCommand.cs:97](KfChatDotNetBot/Commands/NoraCommand.cs#L97) | 97 | 140 chars | `MaxCharacters` constant | +| Execution timeout | [NoraCommand.cs:87](KfChatDotNetBot/Commands/NoraCommand.cs#L87) | 87 | 30 seconds | Max time for the entire command | +| Required permission | [NoraCommand.cs:85](KfChatDotNetBot/Commands/NoraCommand.cs#L85) | 85 | `UserRight.Loser` | Minimum user permission level | +| Max response tokens | [GrokApi.cs:105,122](KfChatDotNetBot/Services/GrokApi.cs#L105) | 105, 122 | 300 tokens | `maxTokens` parameter default | +| Temperature | [GrokApi.cs:167](KfChatDotNetBot/Services/GrokApi.cs#L167) | 167 | 0.7 | Creativity/consistency balance | +| Compaction summary tokens | [ConversationContextManager.cs:127](KfChatDotNetBot/Services/ConversationContextManager.cs#L127) | 127 | 150 tokens | Max tokens for context summary during compaction | + +### Database Settings (via `BuiltIn.cs`) + +These are changed at runtime via admin commands or direct DB updates — no rebuild needed. + +| Setting Key | BuiltIn.cs Field | Line | Default | Description | +|-------------|-----------------|------|---------|-------------| +| `Grok.Nora.Model` | [GrokNoraModel](KfChatDotNetBot/Settings/BuiltIn.cs#L482-L483) | 482-483 | `grok-4-1-fast-reasoning` | Which Grok model to use | +| `Grok.Nora.ContextMode` | [GrokNoraContextMode](KfChatDotNetBot/Settings/BuiltIn.cs#L484-L485) | 484-485 | `perChatter` | `perChatter`, `perRoom`, or `disabled` | +| `Grok.Nora.ContextMaxTokens` | [GrokNoraContextMaxTokens](KfChatDotNetBot/Settings/BuiltIn.cs#L486-L487) | 486-487 | `2400` | Max estimated tokens before context compaction | +| `Grok.Nora.ContextExpiryMinutes` | [GrokNoraContextExpiryMinutes](KfChatDotNetBot/Settings/BuiltIn.cs#L488-L489) | 488-489 | `30` | Minutes of inactivity before context expires | +| `Grok.Nora.UserInfoEnabled` | [GrokNoraUserInfoEnabled](KfChatDotNetBot/Settings/BuiltIn.cs#L490-L491) | 490-491 | `true` | Inject user profile (balance, VIP, etc.) into prompt | +| `Grok.ApiKey` | [GrokApiKey](KfChatDotNetBot/Settings/BuiltIn.cs#L477-L478) | 477-478 | *(empty)* | xAI API key | +| `Grok.Chat.Endpoint` | [GrokChatEndpoint](KfChatDotNetBot/Settings/BuiltIn.cs#L479-L481) | 479-481 | `https://api.x.ai/v1/chat/completions` | API endpoint | + +## Overview + +The `!nora` command allows chat users to interact with Grok AI through the KfChatDotNet bot. All messages are automatically moderated through OpenAI's Moderation API to filter out illegal content while allowing profanity and general offensive language. + +## Features + +- **AI Responses**: Powered by Grok (xAI) with customizable personality +- **Content Moderation**: OpenAI Moderation API blocks illegal content (bomb-making, drug manufacturing, CSAM) +- **Rate Limiting**: 1 use per minute per user to prevent spam and control API costs +- **Input Limits**: 15 words max, 140 characters max +- **Permission Level**: Available to all users (UserRight.Loser) +- **Response Format**: `**Nora to @username:** [AI response]` +- **Hot-reloadable prompt**: Edit `NoraPrompt.txt` and changes take effect on the next invocation + +## Prerequisites + +### 1. OpenAI API Key +- **Purpose**: Content moderation (free tier available) +- **Get it**: https://platform.openai.com/api-keys +- **Cost**: Moderation API is free, but has rate limits +- **Documentation**: https://platform.openai.com/docs/api-reference/moderations + +### 2. Grok API Key (xAI) +- **Purpose**: AI chat completions +- **Get it**: https://console.x.ai/ +- **Cost**: ~$5 per 1M input tokens for grok-4-1-fast-reasoning model +- **Documentation**: https://docs.x.ai/api +- **Pricing**: https://docs.x.ai/developers/models + +## Initial Setup + +### Step 1: Run the Bot Once +On first startup after deploying the code, the bot will automatically create the new settings in the database with default values. + +```bash +dotnet run --project KfChatDotNetBot +``` + +The following settings will be created in `db.sqlite`: +- `OpenAi.ApiKey` (null by default) +- `OpenAi.Moderation.Endpoint` (defaults to `https://api.openai.com/v1/moderations`) +- `Grok.ApiKey` (null by default) +- `Grok.Chat.Endpoint` (defaults to `https://api.x.ai/v1/chat/completions`) +- `Grok.Nora.Model` (defaults to `grok-4-1-fast-reasoning`) +- `Grok.Nora.ContextMode` (defaults to `perChatter` — options: `perChatter`, `perRoom`, `disabled`) +- `Grok.Nora.ContextMaxTokens` (defaults to `2400`) +- `Grok.Nora.ContextExpiryMinutes` (defaults to `30`) + +### Step 2: Configure API Keys + +You need to set the API keys in the database. You can do this either: + +**Option A: Using Admin Commands** (if available in your bot): +``` +!admin setting set OpenAi.ApiKey +!admin setting set Grok.ApiKey +``` + +**Option B: Direct Database Update**: +```bash +# Open the SQLite database +sqlite3 db.sqlite + +# Set OpenAI API key +UPDATE Settings SET Value = 'sk-proj-...' WHERE Key = 'OpenAi.ApiKey'; + +# Set Grok API key +UPDATE Settings SET Value = 'xai-...' WHERE Key = 'Grok.ApiKey'; + +# Exit sqlite +.exit +``` + +**Option C: Using .NET EF Core**: +```csharp +await SettingsProvider.SetValueAsync(BuiltIn.Keys.OpenAiApiKey, "sk-proj-..."); +await SettingsProvider.SetValueAsync(BuiltIn.Keys.GrokApiKey, "xai-..."); +``` + +### Step 3: Edit Nora's Prompt + +Edit [`KfChatDotNetBot/Commands/NoraPrompt.txt`](KfChatDotNetBot/Commands/NoraPrompt.txt) with Nora's personality and context. This file is read at runtime and hot-reloads when modified — no restart needed. + +### Step 4: Restart the Bot +```bash +dotnet run --project KfChatDotNetBot +``` + +The `!nora` command is now ready to use! + +## Configuration Options + +### Customize Nora's Personality + +Edit [`KfChatDotNetBot/Commands/NoraPrompt.txt`](KfChatDotNetBot/Commands/NoraPrompt.txt) directly. The file is re-read whenever it changes on disk — no restart or rebuild required. You can write as much context as you want. + +### Change the AI Model + +If you want to use a different Grok model: + +```sql +UPDATE Settings +SET Value = 'grok-2-latest' +WHERE Key = 'Grok.Nora.Model'; +``` + +Available models: `grok-4-1-fast-reasoning`, `grok-2-latest` (check xAI docs for current models) + +### Use a Proxy + +If you need to route requests through a proxy (applies to both APIs): + +```sql +UPDATE Settings +SET Value = 'http://your-proxy:8080' +WHERE Key = 'Proxy'; +``` + +## How It Works + +``` +User Input: !nora what is 2+2 + | +[1] Input Validation + - Check word count (<=15 words) + - Check character count (<=140 chars) + | +[2] Content Moderation (OpenAI) + - Send to OpenAI Moderation API + - Check for illegal content: + X Block: illicit activities, self-harm instructions, CSAM + OK Allow: profanity, harassment, hate speech + | +[3] Load Prompt + - Read NoraPrompt.txt (cached, hot-reloads on file change) + - Append user info if enabled + - Append random mood + | +[4] AI Response (Grok) + - Send system prompt + conversation context to Grok + - Max 300 tokens response + | +[5] Format & Send + - Format: "Nora to @username: [response]" + - Truncate if needed (1023 byte limit) + - Post to chat +``` + +## Content Moderation Details + +### What Gets Blocked (Illegal Content) +The command blocks content flagged with these OpenAI moderation categories: +- **illicit**: Instructions for illegal activities (bomb-making, drug manufacturing, hacking, etc.) +- **illicit/violent**: Violent illegal activities +- **self-harm/instructions**: Detailed self-harm methods +- **sexual/minors**: Any content involving minors + +### What Gets Allowed (Offensive But Not Illegal) +These are flagged but NOT blocked: +- **harassment**: General insults, harassment +- **hate**: Hate speech +- **sexual**: Adult sexual content +- **violence**: General violence descriptions +- **violence/graphic**: Graphic violence + +This design philosophy allows the command to be used in edgy chat environments while still blocking truly dangerous content. + +## Conversation Context + +The `!nora` command supports persistent conversation context so Nora remembers previous messages within a session. + +### Context Modes + +Set the context mode via the `Grok.Nora.ContextMode` setting: + +| Mode | Behavior | +|------|----------| +| `perChatter` (default) | Each user gets their own separate conversation history | +| `perRoom` | All users in a room share the same conversation history | +| `disabled` | No context — every message is treated independently | + +```sql +-- Per-user context (default) +UPDATE Settings SET Value = 'perChatter' WHERE Key = 'Grok.Nora.ContextMode'; + +-- Shared context for all users in the room +UPDATE Settings SET Value = 'perRoom' WHERE Key = 'Grok.Nora.ContextMode'; + +-- Disable context entirely +UPDATE Settings SET Value = 'disabled' WHERE Key = 'Grok.Nora.ContextMode'; +``` + +### Context Settings + +| Setting | Default | Description | +|---------|---------|-------------| +| `Grok.Nora.ContextMode` | `perChatter` | Context mode: `perChatter`, `perRoom`, or `disabled` | +| `Grok.Nora.ContextMaxTokens` | `2400` | Max estimated tokens before context is compacted via summarization | +| `Grok.Nora.ContextExpiryMinutes` | `30` | Minutes of inactivity before context is automatically cleared | + +### Context Commands + +- `!nora reset` — Clears the conversation context (your own in `perChatter` mode, or the room's in `perRoom` mode) + +### How Context Works + +1. Each message exchange (user + assistant) is stored in memory +2. When the estimated token count exceeds `ContextMaxTokens`, older messages are summarized into a brief summary and only the last 2 messages are kept +3. Contexts expire automatically after `ContextExpiryMinutes` of inactivity +4. A cleanup timer runs every 5 minutes to remove expired contexts + +## Testing the Command + +### Basic Tests +``` +!nora hello -> Should get friendly response +!nora what is 2+2 -> Should get answer +!nora tell me a joke -> Should get a joke +``` + +### Validation Tests +``` +!nora this is one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen +-> Should reject: "your message has 16 words. Maximum is 15 words." + +!nora <141 character message> +-> Should reject: "your message has 141 characters. Maximum is 140 characters." +``` + +### Rate Limit Test +``` +!nora test1 +!nora test2 -> Should show rate limit cooldown message +``` + +### Content Moderation Test +``` +!nora some profanity here +-> Should pass moderation and get response + +!nora how to make a bomb +-> Should block: "your message was blocked for containing illegal content." +``` + +## Troubleshooting + +### "Nora's prompt file is missing" +**Cause**: `NoraPrompt.txt` is not in the expected location. + +**Solutions**: +1. Ensure `Commands/NoraPrompt.txt` exists next to the executable (in `bin/` output directory) +2. If building from source, the file should auto-copy via the csproj `` directive +3. Check that the file isn't empty + +### "Moderation service is currently unavailable" +**Cause**: OpenAI API key is missing, invalid, or the API is down. + +**Solutions**: +1. Check that `OpenAi.ApiKey` is set correctly in the database +2. Verify the API key is valid at https://platform.openai.com/api-keys +3. Check OpenAI's status page: https://status.openai.com/ +4. Check bot logs for detailed error messages + +### "Nora is currently unavailable" +**Cause**: Grok API key is missing, invalid, or the API is down. + +**Solutions**: +1. Check that `Grok.ApiKey` is set correctly in the database +2. Verify the API key is valid at https://console.x.ai/ +3. Check xAI's status +4. Check bot logs for detailed error messages + +### Command Not Responding +**Cause**: Command might not be loaded or user might be rate limited. + +**Solutions**: +1. Check that the bot successfully started (check logs) +2. Verify the command is loaded: `!help` should show "nora" +3. Wait 1 minute if you've hit the rate limit +4. Check user's permission level (should work for all users) + +### Responses Are Cut Off +**Cause**: Sneedchat has a 1023-byte message limit. + +**Solutions**: +- This is expected behavior - long responses are automatically truncated +- Adjust `maxTokens` default in [GrokApi.cs:105](KfChatDotNetBot/Services/GrokApi.cs#L105) if you want shorter responses +- Edit `NoraPrompt.txt` to request briefer answers + +## Cost Monitoring + +### OpenAI Moderation API +- **Cost**: Free +- **Rate Limits**: Check your quota at https://platform.openai.com/account/limits +- **Usage**: View at https://platform.openai.com/usage + +### Grok API (xAI) +- **Cost**: ~$5 per 1M input tokens (varies by model) +- **Rate Limits**: Set at 1 use/user/minute in the bot +- **Usage**: Monitor at https://console.x.ai/ +- **Budget**: Consider setting usage alerts in xAI console + +### Example Cost Calculation +``` +Average message: 10 words (~15 tokens input) +Average response: 50 tokens output +Cost per interaction: ~$0.0003 (assuming $5/1M tokens) + +At 1 request/user/minute: +- 100 users: ~100 requests/hour max = ~$0.03/hour +- 1000 users: ~1000 requests/hour max = ~$0.30/hour + +Rate limiting prevents runaway costs! +``` + +## Security Considerations + +### API Key Storage +- API keys are stored in `db.sqlite` with the `IsSecret` flag +- They are not displayed in logs when the `IsSecret` flag is set +- Keep `db.sqlite` secure and don't commit it to version control + +### Content Logging +The bot logs: +- All moderation results (flagged categories) +- Blocked illegal content attempts with usernames +- Allowed-but-flagged content (profanity) for monitoring + +Check logs at: `logs/` directory (configured in `NLog.config`) + +### Rate Limiting +- 1 use per minute per user prevents spam +- No global rate limit by default (all users can use simultaneously) +- Consider adding `RateLimitFlags.Global` if abuse is a concern + +## Advanced Configuration + +### Add Global Rate Limit +Edit [NoraCommand.cs:89-94](KfChatDotNetBot/Commands/NoraCommand.cs#L89-L94): + +```csharp +public RateLimitOptionsModel? RateLimitOptions => new RateLimitOptionsModel +{ + Window = TimeSpan.FromMinutes(1), + MaxInvocations = 1, + Flags = RateLimitFlags.Global // Add this flag +}; +``` + +This limits ALL users combined to 1 request per minute. + +### Increase Response Length +Edit [GrokApi.cs:105](KfChatDotNetBot/Services/GrokApi.cs#L105): + +```csharp +max_tokens = 500 // Increase from 300 to 500 +``` + +Note: Longer responses may get truncated by Sneedchat's 1023-byte limit. + +### Change Input Limits +Edit [NoraCommand.cs:96-97](KfChatDotNetBot/Commands/NoraCommand.cs#L96-L97): + +```csharp +private const int MaxWords = 20; // Increase from 15 to 20 +private const int MaxCharacters = 200; // Increase from 140 to 200 +``` + +### Require Higher Permissions +Edit [NoraCommand.cs:85](KfChatDotNetBot/Commands/NoraCommand.cs#L85): + +```csharp +public UserRight RequiredRight => UserRight.TrueAndHonest; // Mods only +``` + +## Code Architecture + +### Files +- **[KfChatDotNetBot/Commands/NoraPrompt.txt](KfChatDotNetBot/Commands/NoraPrompt.txt)** - System prompt (hot-reloadable) +- **[KfChatDotNetBot/Commands/NoraCommand.cs](KfChatDotNetBot/Commands/NoraCommand.cs)** - Main command logic +- **[KfChatDotNetBot/Commands/NoraMoods.cs](KfChatDotNetBot/Commands/NoraMoods.cs)** - Random mood modifiers +- **[KfChatDotNetBot/Services/OpenAiModeration.cs](KfChatDotNetBot/Services/OpenAiModeration.cs)** - OpenAI moderation API integration +- **[KfChatDotNetBot/Services/GrokApi.cs](KfChatDotNetBot/Services/GrokApi.cs)** - Grok API integration +- **[KfChatDotNetBot/Services/ConversationContextManager.cs](KfChatDotNetBot/Services/ConversationContextManager.cs)** - Conversation context and compaction +- **[KfChatDotNetBot/Settings/BuiltIn.cs](KfChatDotNetBot/Settings/BuiltIn.cs)** - DB setting keys + +### Auto-Discovery +The command is automatically discovered via reflection in [BotCommands.cs](KfChatDotNetBot/Services/BotCommands.cs) on bot startup. No manual registration needed! + +## FAQ + +**Q: Can I use a different AI provider instead of Grok?** +A: Yes! The code is modular. Create a new service similar to `GrokApi.cs` and update `NoraCommand.cs` to use it. + +**Q: Why is moderation required?** +A: To prevent the bot from being used to generate illegal or dangerous content that could create liability. + +**Q: Can I disable moderation?** +A: Not recommended, but technically you could modify `NoraCommand.cs` to skip the moderation step. This is strongly discouraged. + +**Q: What if OpenAI moderation is down?** +A: The command fails-safe and blocks all messages when moderation is unavailable, preventing unmoderated content from reaching the AI. + +**Q: Can I increase the rate limit?** +A: Yes, edit the `RateLimitOptions` in `NoraCommand.cs`. Be aware this increases API costs. + +**Q: Does this work with the GambaSesh detection system?** +A: Yes! The command respects the bot's existing systems. Responses are sent with `bypassSeshDetect: true` to ensure they're always posted. + +**Q: How do I edit Nora's prompt?** +A: Edit `KfChatDotNetBot/Commands/NoraPrompt.txt` (or `Commands/NoraPrompt.txt` in the build output directory). Changes are picked up automatically — no restart needed. diff --git a/PRIVACY.md b/PRIVACY.md new file mode 100644 index 0000000..49eda84 --- /dev/null +++ b/PRIVACY.md @@ -0,0 +1,96 @@ +# 🔒 Security & Privacy Guide + +**Important:** If you're contributing to this project and want to maintain anonymity, follow these steps to prevent leaking personal information. + +## Table of Contents + +- [Anonymize Your Git Identity](#anonymize-your-git-identity) +- [Prevent Path Leaks](#prevent-path-leaks) + +## Anonymize Your Git Identity + +Configure Git to use a pseudonymous identity for this repository: + +```bash +# Navigate to the repository +cd KfChatDotNet + +# Set local Git identity (only affects this repo) +git config user.name "YourPseudonym" +git config user.email "pseudonym@example.com" + +# Or use GitHub's noreply email +git config user.email "username@users.noreply.github.com" + +# Verify your settings +git config user.name +git config user.email +``` + +> **Note:** Use `git config` (without `--global`) to set identity per-repository only. This won't affect your other projects. + +### Check Your Current Identity + +Before making commits, verify what identity will be used: + +```bash +# Check current configuration +git config --list --show-origin | grep user + +# See what will be used for the next commit +git config user.name +git config user.email +``` + +## Prevent Path Leaks + +Your system username and file paths can leak into commits through: + +### 1. Exception Stack Traces + +Be careful when committing error logs or debugging output that might contain paths like: +- ❌ `/home/john.smith/repos/KfChatDotNet/` +- ❌ `C:\Users\JohnSmith\Documents\KfChatDotNet\` +- ❌ `/Users/john.smith/Projects/KfChatDotNet/` + +**What to do:** +- Strip paths from error logs before committing +- Use relative paths in documentation +- Sanitize stack traces to remove usernames + +### 2. Absolute Paths in Code + +Avoid hardcoding absolute paths. Use relative paths or configuration: + +```csharp +// ❌ Bad - leaks username +var logPath = "/home/john.smith/.bot/logs/"; +var dataPath = "C:\\Users\\JohnSmith\\AppData\\bot\\"; + +// ✅ Good - use relative or configurable paths +var logPath = Path.Combine(Environment.CurrentDirectory, "logs"); +var dataPath = Path.Combine(AppContext.BaseDirectory, "data"); +var configPath = await SettingsProvider.GetValueAsync("Bot.LogPath"); +``` + +### 3. IDE Configuration Files + +The `.gitignore` already excludes common IDE files, but verify: + +```bash +# Check what's being tracked +git ls-files | grep -E '\.(user|suo|vscode|idea)' + +# If you find personal IDE files, remove them +git rm --cached path/to/personal/file +git commit -m "Remove personal IDE files" +``` + +### 4. Database Files + +The database (`db.sqlite`) can contain: +- Your KiwiFarms password (in Settings table) +- Your KiwiFarms cookies +- Potentially identifiable usage patterns + +**Never commit `db.sqlite`** - it's already in `.gitignore`. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..8379382 --- /dev/null +++ b/README.md @@ -0,0 +1,293 @@ +# KfChatDotNet + +> ## ⚠️ **IMPORTANT: Read This First** ⚠️ +> +> **If you're contributing to this project, please read [PRIVACY.md](PRIVACY.md) to learn how to protect your identity.** + +--- +A C# .NET chat bot for KiwiFarms (Sneedchat) with extensive third-party integrations for stream monitoring, gambling site tracking, and interactive commands. + +## Features + +- 🤖 **Interactive Chat Bot** - Command-based interactions with regex pattern matching +- 🎰 **Kasino System** - Internal gambling games (mines, limbo, coinflip, blackjack, roulette, and more) +- 📺 **Stream Monitoring** - Track streams across Twitch, Kick, DLive, PeerTube, Owncast, YouTube +- 💬 **Multi-Platform Integration** - Discord relay, Twitch chat, Kick events +- 🎲 **Gambling Tracking** - Monitor bets across Rainbet, Shuffle, Howlgg, Chips.gg, Clash.gg, and more +- 🔄 **Auto-Reconnect** - Robust WebSocket connection with automatic cookie refresh +- 🗃️ **Database-Backed Settings** - SQLite database with EF Core migrations + +## Prerequisites + +- [.NET 10.0 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) or higher +- A KiwiFarms account +- (Optional) SQLite viewer for database inspection + +## Quick Start + +### 1. Clone and Build + +```bash +git clone +cd KfChatDotNet +dotnet build +``` + +### 2. Configure Credentials + +The bot uses a **database-backed settings system**. On first run, it will create `db.sqlite` with default settings. + +#### Option A: Using config.json (Easiest for First Run) + +Create `KfChatDotNetBot/config.json`: + +```json +{ + "KfUsername": "your_kiwifarms_username", + "KfPassword": "your_kiwifarms_password", + "KfChatRoomId": 15, + "KfDomain": "kiwifarms.st", + "KfWsEndpoint": "wss://kiwifarms.st:9443/chat.ws", + "SuppressChatMessages": true +} +``` + +> **Note:** The bot will automatically migrate `config.json` to the database on first run and rename it to `config.json.migrated`. + +#### Option B: Directly Edit Database (After First Run) + +Build the project, then run the bot once. This generates tables which can be updated by running `sqlite`: + +```bash +# Build +dotnet build + +# Run +dotnet run --project KfChatDotNetBot + +# Ctr + C to stop the application, then run +sqlite3 db.sqlite +``` + +```sql +-- Update your credentials +UPDATE Settings SET Value = 'your_username' WHERE Key = 'KiwiFarms.Username'; +UPDATE Settings SET Value = 'your_password' WHERE Key = 'KiwiFarms.Password'; + +-- Enable read-only mode for testing +UPDATE Settings SET Value = 'true' WHERE Key = 'KiwiFarms.SuppressChatMessages'; +``` + +### 3. Run the Bot + +```bash +cd KfChatDotNetBot +dotnet run +``` + +The bot will: +1. ✅ Create/migrate the database +2. ✅ Sync built-in settings +3. ✅ Connect to KiwiFarms chat +4. ✅ Start listening for commands + +## Configuration Guide + +### Essential Settings + +| Setting | Description | Default | Required | +|---------|-------------|---------|----------| +| `KiwiFarms.Username` | Your KF username | - | ✅ Yes | +| `KiwiFarms.Password` | Your KF password | - | ✅ Yes | +| `KiwiFarms.RoomId` | Chat room ID to join | `15` | ✅ Yes | +| `KiwiFarms.SuppressChatMessages` | Read-only mode (no messages sent) | `false` | Recommended for testing | + +### Optional Integrations + +All external services are **disabled by default** or optional: + +| Service | Enable Setting | Auth Required | +|---------|---------------|---------------| +| Discord | `Discord.Token` | Bot token | +| Kick | `Kick.Enabled` | None (public API) | +| Twitch | Auto-enabled | None (public GraphQL) | +| Jackpot | `Jackpot.Enabled` | None | +| Rainbet | `Rainbet.Enabled` | None | +| Shuffle | Auto-enabled | None | +| YouTube | `YouTube.ApiKey` | API key | +| FlareSolverr | `FlareSolverr.ApiUrl` | Service URL | + +### Viewing All Settings + +```bash +# View non-secret settings +sqlite3 db.sqlite "SELECT Key, Value, Description FROM Settings WHERE IsSecret = 0 LIMIT 20;" + +# View secret settings (passwords, tokens) +sqlite3 db.sqlite "SELECT Key, Value FROM Settings WHERE IsSecret = 1;" +``` + +### Testing Mode + +For safe testing without sending messages to chat: + +```sql +UPDATE Settings SET Value = 'true' WHERE Key = 'KiwiFarms.SuppressChatMessages'; +``` + +When enabled, the bot will connect, listen, and process commands but **won't actually send messages**. + +## Development + +### Project Structure + +``` +KfChatDotNet/ +├── KfChatDotNetWsClient/ # WebSocket client library for KF chat +├── KickWsClient/ # WebSocket client library for Kick +└── KfChatDotNetBot/ # Main bot application + ├── Commands/ # Bot commands (implement ICommand) + ├── Services/ # External service integrations + ├── Models/ # Data models and DTOs + ├── Migrations/ # EF Core database migrations + └── Settings/ # Settings system +``` + +### Common Commands + +```bash +# Build the solution +dotnet build + +# Run in release mode +dotnet run --project KfChatDotNetBot -c Release + +# Clean build artifacts +dotnet clean + +# Add a database migration +dotnet ef migrations add MigrationName --project KfChatDotNetBot + +# Update database to latest migration +dotnet ef database update --project KfChatDotNetBot + +# View migration SQL +dotnet ef migrations script --project KfChatDotNetBot +``` + +### Adding a New Command + +1. Create a class in `KfChatDotNetBot/Commands/` implementing `ICommand` +2. Define regex patterns and command logic +3. The bot auto-discovers commands via reflection on startup + +Example: + +```csharp +public class HelloCommand : ICommand +{ + public List Patterns => [new Regex(@"^hello$", RegexOptions.IgnoreCase)]; + public string? HelpText => "Says hello!"; + public UserRight RequiredRight => UserRight.User; + public TimeSpan Timeout => TimeSpan.FromSeconds(10); + public RateLimitOptionsModel? RateLimitOptions => null; + + public async Task RunCommand(ChatBot botInstance, MessageModel message, + UserDbModel user, GroupCollection arguments, CancellationToken ctx) + { + botInstance.SendChatMessage($"Hello, @{message.Author.Username}!"); + } +} +``` + +### Database Migrations + +Migrations run **automatically** on startup. The database schema is managed via Entity Framework Core. + +```bash +# Create a new migration +dotnet ef migrations add AddNewFeature --project KfChatDotNetBot + +# Migrations are applied automatically when the bot starts +dotnet run --project KfChatDotNetBot +``` + +## Architecture Overview + +### Message Flow + +``` +KiwiFarms Chat (WebSocket) + ↓ + ChatClient (WsClient library) + ↓ + ChatBot (Event handlers) + ↓ + BotCommands (Pattern matching) + ↓ + ICommand implementations (Your commands) +``` + +### Key Components + +- **ChatClient** - WebSocket connection to KF, handles reconnection and cookie management +- **ChatBot** - Main bot orchestration, message tracking, GambaSesh detection +- **BotCommands** - Command routing with regex patterns, rate limiting, permissions +- **BotServices** - Manages all external service connections (Discord, Twitch, gambling sites) +- **Money** - Internal Kasino gambling system with balance tracking + +### Settings System + +Settings are **stored in the database**, not config files: + +- All settings have defaults defined in `BuiltIn.cs` +- Settings are cached to reduce database queries +- Secrets are marked and hidden from logs +- Settings sync automatically on startup + +For more architectural details, see [CLAUDE.md](CLAUDE.md). + +## Logging + +Logging is configured via `NLog.config`. The bot logs extensively: + +- **Debug**: Packet details, message processing, connection events +- **Info**: User joins/parts, sent messages, state changes +- **Error**: Exceptions, disconnections, failed operations + +## Optional Services Setup + +### FlareSolverr (for Cloudflare bypass) + +```bash +# Using Docker +docker run -d \ + --name=flaresolverr \ + -p 8191:8191 \ + ghcr.io/flaresolverr/flaresolverr:latest + +# Update setting +UPDATE Settings SET Value = 'http://localhost:8191/' WHERE Key = 'FlareSolverr.ApiUrl'; +``` + +### Discord Bot + +1. Create a bot at [Discord Developer Portal](https://discord.com/developers/applications) +2. Get your bot token +3. Update setting: `UPDATE Settings SET Value = 'your_bot_token' WHERE Key = 'Discord.Token';` + +### Redis (for YouTube PubSub) + +```bash +# Using Docker +docker run -d --name redis -p 6379:6379 redis:latest + +# Update setting +UPDATE Settings SET Value = 'localhost:6379' WHERE Key = 'YouTube.PubSub.RedisConnectionString'; +``` + +## License + +This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + +See [LICENSE](LICENSE) for details. \ No newline at end of file