Migrated moods and prompts to the settings.

Removed the weird concurrent dictionary and replaced with Redis.
Removed the cleanup watchdog in favor of Redis expiration
This commit is contained in:
barelyprofessional
2026-02-17 22:02:09 -06:00
parent 75e958cd2a
commit 0dcbb25fe3
7 changed files with 126 additions and 190 deletions

View File

@@ -1,4 +1,5 @@
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using Humanizer;
using KfChatDotNetBot.Extensions; using KfChatDotNetBot.Extensions;
using KfChatDotNetBot.Models; using KfChatDotNetBot.Models;
using KfChatDotNetBot.Models.DbModels; using KfChatDotNetBot.Models.DbModels;
@@ -36,70 +37,28 @@ public class NoraCommand : ICommand
{ {
private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); 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 => [ public List<Regex> Patterns => [
new Regex(@"^nora\s+(?<message>.+)", RegexOptions.IgnoreCase) 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 string HelpText => "Ask Nora AI a question (max 15 words, 140 chars). Use '!nora reset' to clear context.";
public UserRight RequiredRight => UserRight.Loser; public UserRight RequiredRight => UserRight.Loser;
public TimeSpan Timeout => TimeSpan.FromSeconds(30); public TimeSpan Timeout => TimeSpan.FromSeconds(30);
public RateLimitOptionsModel? RateLimitOptions => new RateLimitOptionsModel public RateLimitOptionsModel RateLimitOptions => new()
{ {
Window = TimeSpan.FromMinutes(1), Window = TimeSpan.FromMinutes(1),
MaxInvocations = 3, MaxInvocations = 3,
Flags = RateLimitFlags.None Flags = RateLimitFlags.None
}; };
private const int MaxWords = 30;
private const int MaxCharacters = 300;
public async Task RunCommand(ChatBot botInstance, MessageModel message, UserDbModel user, public async Task RunCommand(ChatBot botInstance, MessageModel message, UserDbModel user,
GroupCollection arguments, CancellationToken ctx) GroupCollection arguments, CancellationToken ctx)
{ {
var userMessage = arguments["message"].Value.Trim(); var userMessage = arguments["message"].Value.Trim();
var manager = new ConversationContextManager();
// Handle !nora reset — clear conversation context // Handle !nora reset — clear conversation context
if (userMessage.Equals("reset", StringComparison.OrdinalIgnoreCase)) if (userMessage.Equals("reset", StringComparison.OrdinalIgnoreCase))
@@ -116,8 +75,8 @@ public class NoraCommand : ICommand
return; return;
} }
var resetKey = ConversationContextManager.GetContextKey(mode, user.KfId, message.RoomId); var resetKey = ConversationContextManager.GetContextKeyAsync(mode, user.KfId, message.RoomId);
var cleared = ConversationContextManager.ClearContext(resetKey); var cleared = await manager.ClearContextAsync(resetKey);
await botInstance.SendChatMessageAsync( await botInstance.SendChatMessageAsync(
cleared cleared
? $"{user.FormatUsername()}, your conversation context has been cleared." ? $"{user.FormatUsername()}, your conversation context has been cleared."
@@ -127,22 +86,24 @@ public class NoraCommand : ICommand
return; return;
} }
var maxWords = (await SettingsProvider.GetValueAsync(BuiltIn.Keys.GrokNoraMaxWords)).ToType<int>();
var maxCharacters = (await SettingsProvider.GetValueAsync(BuiltIn.Keys.GrokNoraMaxCharacters)).ToType<int>();
// Validate word count // Validate word count
var wordCount = userMessage.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length; var wordCount = userMessage.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length;
if (wordCount > MaxWords) if (wordCount > maxWords)
{ {
await botInstance.SendChatMessageAsync( await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, your message has {wordCount} words. Maximum is {MaxWords} words.", $"{user.FormatUsername()}, your message has {wordCount} words. Maximum is {maxWords} words.",
true, true,
autoDeleteAfter: TimeSpan.FromSeconds(15)); autoDeleteAfter: TimeSpan.FromSeconds(15));
return; return;
} }
// Validate character count // Validate character count
if (userMessage.Length > MaxCharacters) if (userMessage.Length > maxCharacters)
{ {
await botInstance.SendChatMessageAsync( await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, your message has {userMessage.Length} characters. Maximum is {MaxCharacters} characters.", $"{user.FormatUsername()}, your message has {userMessage.Length} characters. Maximum is {maxCharacters} characters.",
true, true,
autoDeleteAfter: TimeSpan.FromSeconds(15)); autoDeleteAfter: TimeSpan.FromSeconds(15));
return; return;
@@ -177,7 +138,7 @@ public class NoraCommand : ICommand
} }
// Step 2: Build conversation context and get Grok AI response // Step 2: Build conversation context and get Grok AI response
var basePrompt = LoadPrompt(); var basePrompt = (await SettingsProvider.GetValueAsync(BuiltIn.Keys.GrokNoraPrompt)).Value;
if (basePrompt == null) if (basePrompt == null)
{ {
Logger.Error("Nora prompt file is missing or unreadable"); Logger.Error("Nora prompt file is missing or unreadable");
@@ -200,7 +161,7 @@ public class NoraCommand : ICommand
// Compute context key once (used for mood and later for context messages) // Compute context key once (used for mood and later for context messages)
string? contextKey = null; string? contextKey = null;
if (!contextDisabled) if (!contextDisabled)
contextKey = ConversationContextManager.GetContextKey(contextMode, user.KfId, message.RoomId); contextKey = ConversationContextManager.GetContextKeyAsync(contextMode, user.KfId, message.RoomId);
// Optionally inject user info into the system prompt // Optionally inject user info into the system prompt
var userInfoEnabled = settings[BuiltIn.Keys.GrokNoraUserInfoEnabled].Value?.Equals("true", StringComparison.OrdinalIgnoreCase) == true; var userInfoEnabled = settings[BuiltIn.Keys.GrokNoraUserInfoEnabled].Value?.Equals("true", StringComparison.OrdinalIgnoreCase) == true;
@@ -209,23 +170,27 @@ public class NoraCommand : ICommand
var infoParts = new List<string> var infoParts = new List<string>
{ {
$"Username: {user.KfUsername}", $"Username: {user.KfUsername}",
$"Permission level: {user.UserRight}" $"Permission level: {user.UserRight.Humanize()}"
}; };
var gambler = await Money.GetGamblerEntityAsync(user.Id, createIfNoneExists: false, ct: ctx); var gambler = await Money.GetGamblerEntityAsync(user.Id, ct: ctx);
if (gambler != null && gambler.State == GamblerState.Active) if (gambler is { State: GamblerState.Active })
{ {
infoParts.Add($"Kasino balance: {gambler.Balance:N2} KKK"); infoParts.Add($"Kasino balance: {await gambler.Balance.FormatKasinoCurrencyAsync()}");
infoParts.Add($"Total wagered: {gambler.TotalWagered:N2} KKK"); infoParts.Add($"Total wagered: {await gambler.TotalWagered.FormatKasinoCurrencyAsync()}");
var vipPerk = await Money.GetVipLevelAsync(gambler, ctx); var vipPerk = await Money.GetVipLevelAsync(gambler, ctx);
if (vipPerk != null) if (vipPerk != null)
{
infoParts.Add($"VIP rank: {vipPerk.PerkName} (Tier {vipPerk.PerkTier})"); infoParts.Add($"VIP rank: {vipPerk.PerkName} (Tier {vipPerk.PerkTier})");
}
else else
{
infoParts.Add("VIP rank: None (hasn't reached first VIP level)"); infoParts.Add("VIP rank: None (hasn't reached first VIP level)");
}
} }
else else
{ {
infoParts.Add("Kasino status: Not an active gambler"); infoParts.Add("Kasino status: Permanently excluded");
} }
systemPrompt += "\n\nThe customer you are currently speaking to has the following profile:\n" + string.Join("\n", infoParts); systemPrompt += "\n\nThe customer you are currently speaking to has the following profile:\n" + string.Join("\n", infoParts);
@@ -233,8 +198,8 @@ public class NoraCommand : ICommand
// Inject mood into system prompt // Inject mood into system prompt
var mood = contextKey != null var mood = contextKey != null
? ConversationContextManager.GetOrAssignMood(contextKey) ? await manager.GetOrAssignMoodAsync(contextKey)
: NoraMoods.GetRandomMood(); : await ConversationContextManager.GetRandomMoodAsync();
systemPrompt += "\n\n" + mood; systemPrompt += "\n\n" + mood;
string? grokResponse; string? grokResponse;
@@ -254,7 +219,7 @@ public class NoraCommand : ICommand
? $"{user.KfUsername}: {userMessage}" ? $"{user.KfUsername}: {userMessage}"
: userMessage; : userMessage;
var contextMessages = ConversationContextManager.GetMessagesForApi(contextKey!); var contextMessages = await manager.GetMessagesForApiAsync(contextKey!);
contextMessages.Add(new ConversationMessage { Role = "user", Content = contentForContext }); contextMessages.Add(new ConversationMessage { Role = "user", Content = contentForContext });
grokResponse = await GrokApi.GetChatCompletionAsync(systemPrompt, contextMessages); grokResponse = await GrokApi.GetChatCompletionAsync(systemPrompt, contextMessages);
@@ -262,11 +227,11 @@ public class NoraCommand : ICommand
if (grokResponse != null) if (grokResponse != null)
{ {
// Store the exchange in context // Store the exchange in context
ConversationContextManager.AddMessage(contextKey!, "user", contentForContext); await manager.AddMessageAsync(contextKey!, "user", contentForContext);
ConversationContextManager.AddMessage(contextKey!, "assistant", grokResponse); await manager.AddMessageAsync(contextKey!, "assistant", grokResponse);
// Compact if context is getting too large // Compact if context is getting too large
await ConversationContextManager.CompactIfNeededAsync(contextKey!); await manager.CompactIfNeededAsync(contextKey!);
} }
} }
@@ -296,7 +261,6 @@ public class NoraCommand : ICommand
await botInstance.SendChatMessageAsync( await botInstance.SendChatMessageAsync(
formattedResponse, formattedResponse,
true, true);
ChatBot.LengthLimitBehavior.TruncateNicely);
} }
} }

View File

@@ -1,37 +0,0 @@
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

@@ -1,32 +0,0 @@
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,9 +49,6 @@
<Content Include="NLog.config"> <Content Include="NLog.config">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content> </Content>
<Content Include="Commands/NoraPrompt.txt">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

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

View File

@@ -1,6 +1,9 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using KfChatDotNetBot.Settings; using KfChatDotNetBot.Settings;
using Newtonsoft.Json;
using NLog; using NLog;
using StackExchange.Redis;
using JsonSerializer = System.Text.Json.JsonSerializer;
namespace KfChatDotNetBot.Services; namespace KfChatDotNetBot.Services;
@@ -29,52 +32,96 @@ public class ConversationContext
} }
} }
public static class ConversationContextManager public class ConversationContextManager
{ {
private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); private static readonly Logger Logger = LogManager.GetCurrentClassLogger();
private static readonly ConcurrentDictionary<string, ConversationContext> Contexts = new();
private static Task? _cleanupTask; private static Task? _cleanupTask;
private static CancellationToken _cancellationToken; private static CancellationToken _cancellationToken;
private IDatabase _redisDb;
public static void StartCleanupTimer(CancellationToken cancellationToken) public ConversationContextManager()
{ {
_cancellationToken = cancellationToken; var connectionString = SettingsProvider.GetValueAsync(BuiltIn.Keys.BotRedisConnectionString).Result;
_cleanupTask = CleanupLoop(); if (string.IsNullOrEmpty(connectionString.Value))
{
Logger.Error($"Can't initialize the Nora ConversationContextManager service as Redis isn't configured in {BuiltIn.Keys.BotRedisConnectionString}");
throw new InvalidOperationException("Redis isn't configured");
}
var redis = ConnectionMultiplexer.Connect(connectionString.Value);
_redisDb = redis.GetDatabase();
} }
public static string GetContextKey(string mode, int userId, int roomId) public static string GetContextKeyAsync(string mode, int userId, int roomId)
{ {
return mode.ToLowerInvariant() switch return mode.ToLowerInvariant() switch
{ {
"perchatter" => $"user:{userId}", "perchatter" => $"Nora:User:{userId}",
"perroom" => $"room:{roomId}", "perroom" => $"Nora:Room:{roomId}",
_ => $"user:{userId}" // fallback to per-chatter _ => $"Nora:User:{userId}" // fallback to per-chatter
}; };
} }
public static string GetOrAssignMood(string contextKey) public async Task<string> GetOrAssignMoodAsync(string contextKey)
{ {
var context = Contexts.GetOrAdd(contextKey, _ => new ConversationContext()); var data = await _redisDb.StringGetAsync(contextKey);
var context = new ConversationContext();
if (data.HasValue)
{
context = JsonSerializer.Deserialize<ConversationContext>(data.ToString());
}
if (context == null)
{
throw new InvalidOperationException($"Caught a null when deserializing {contextKey}");
}
if (context.Mood == null) if (context.Mood == null)
{ {
context.Mood = Commands.NoraMoods.GetRandomMood(); context.Mood = await GetRandomMoodAsync();
Logger.Debug($"Assigned mood for {contextKey}: {context.Mood}"); Logger.Debug($"Assigned mood for {contextKey}: {context.Mood}");
var expiration =
TimeSpan.FromMinutes((await SettingsProvider.GetValueAsync(BuiltIn.Keys.GrokNoraContextExpiryMinutes))
.ToType<int>());
await _redisDb.StringSetAsync(contextKey, JsonSerializer.Serialize(context), expiration, When.Always);
} }
return context.Mood; return context.Mood;
} }
public static void AddMessage(string contextKey, string role, string content) public async Task AddMessageAsync(string contextKey, string role, string content)
{ {
var context = Contexts.GetOrAdd(contextKey, _ => new ConversationContext()); var data = await _redisDb.StringGetAsync(contextKey);
var context = new ConversationContext();
if (data.HasValue)
{
context = JsonSerializer.Deserialize<ConversationContext>(data.ToString());
}
if (context == null)
{
throw new InvalidOperationException($"Caught a null when deserializing {contextKey}");
}
context.Messages.Add(new ConversationMessage { Role = role, Content = content }); context.Messages.Add(new ConversationMessage { Role = role, Content = content });
context.LastActivity = DateTime.UtcNow; context.LastActivity = DateTime.UtcNow;
context.RecalculateTokens(); context.RecalculateTokens();
var expiration =
TimeSpan.FromMinutes((await SettingsProvider.GetValueAsync(BuiltIn.Keys.GrokNoraContextExpiryMinutes))
.ToType<int>());
await _redisDb.StringSetAsync(contextKey, JsonSerializer.Serialize(context), expiration, When.Always);
} }
public static List<ConversationMessage> GetMessagesForApi(string contextKey) public async Task<List<ConversationMessage>> GetMessagesForApiAsync(string contextKey)
{ {
if (!Contexts.TryGetValue(contextKey, out var context)) var data = await _redisDb.StringGetAsync(contextKey);
if (data.IsNullOrEmpty)
{
return []; return [];
}
var context = JsonSerializer.Deserialize<ConversationContext>(data.ToString());
if (context == null)
{
throw new InvalidOperationException($"Caught a null when deserializing {contextKey}");
}
var messages = new List<ConversationMessage>(); var messages = new List<ConversationMessage>();
@@ -91,10 +138,19 @@ public static class ConversationContextManager
return messages; return messages;
} }
public static async Task CompactIfNeededAsync(string contextKey) public async Task CompactIfNeededAsync(string contextKey)
{ {
if (!Contexts.TryGetValue(contextKey, out var context)) var data = await _redisDb.StringGetAsync(contextKey);
if (data.IsNullOrEmpty)
{
return; return;
}
var context = JsonSerializer.Deserialize<ConversationContext>(data.ToString());
if (context == null)
{
throw new InvalidOperationException($"Caught a null when deserializing {contextKey}");
}
var maxTokensSetting = await SettingsProvider.GetValueAsync(BuiltIn.Keys.GrokNoraContextMaxTokens); var maxTokensSetting = await SettingsProvider.GetValueAsync(BuiltIn.Keys.GrokNoraContextMaxTokens);
var maxTokens = int.TryParse(maxTokensSetting.Value, out var mt) ? mt : 800; var maxTokens = int.TryParse(maxTokensSetting.Value, out var mt) ? mt : 800;
@@ -140,43 +196,24 @@ public static class ConversationContextManager
context.Messages = toKeep; context.Messages = toKeep;
context.RecalculateTokens(); context.RecalculateTokens();
} }
var expiration =
TimeSpan.FromMinutes((await SettingsProvider.GetValueAsync(BuiltIn.Keys.GrokNoraContextExpiryMinutes))
.ToType<int>());
await _redisDb.StringSetAsync(contextKey, JsonSerializer.Serialize(context), expiration, When.Always);
} }
public static bool ClearContext(string contextKey) public async Task<bool> ClearContextAsync(string contextKey)
{ {
return Contexts.TryRemove(contextKey, out _); return await _redisDb.KeyDeleteAsync(contextKey);
} }
private static async Task CleanupLoop() public static async Task<string> GetRandomMoodAsync()
{ {
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(5)); var moods = (await SettingsProvider.GetValueAsync(BuiltIn.Keys.GrokNoraMoods)).JsonDeserialize<List<string>>();
while (await timer.WaitForNextTickAsync(_cancellationToken)) if (moods == null)
{ {
try throw new InvalidOperationException("Caught a null when deserializing Nora's moods");
{
await CleanupExpired();
}
catch (Exception ex)
{
Logger.Error(ex, "Error during conversation context cleanup");
}
} }
} return moods[Random.Shared.Next(moods.Count)];
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

@@ -489,6 +489,14 @@ public static class BuiltIn
public static string GrokNoraContextExpiryMinutes = "Grok.Nora.ContextExpiryMinutes"; 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)] [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"; public static string GrokNoraUserInfoEnabled = "Grok.Nora.UserInfoEnabled";
[BuiltInSetting("Nora's prompt", SettingValueType.Text, "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.\nYou 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.\n\nPeople of interest:\nBossmanJack 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\".\nBossman 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)\n\"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.\nBossman 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.\n\nRatdad 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.\nShoovy, aka boatnigga is a Kick IRL streamer. He is extremely stupid. \nIan 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.\nPirateSoftware. 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.\nChrisDJ. 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.\nEthan 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)\nsifu Paranoid Schizophrenic who streams on Youtube, complains about bitrape.\nNick 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.\nCasino Owners (riggers): Niggardly Noah (Shuffle), Evil Eddie (Stake), Dastardly Dallas, Bastard Bean (Howl).\nHardin Null's lawyer.\nAvelloon. An evil balloon. Quite dastardly. Loves to scheme.\nkeffals\nliz fong jones\n\nChat Users:\n@Null AKA Joshua Moon. Ooperator of the Kiwi Farms. Loves cheese. \n@kees, MIA chat legend. Used to be a main contributor to the Bossman thread. He had a sanic pfp. He was a chat regular.\n@Electric Mortar & Pestle, i am the faggotiest nigger to ever faggot nigger\n@Bloitzhole, Goes hiking. Not fat. Allegedly has a big butt.\n@, literally null\n@BiggestBuford, pretends to be a burger on a hate site\n@Oxiclean Crack Addict, tough guy\n@baws man jack, a clanker slave master do as he says")]
public static string GrokNoraPrompt = "Grok.Nora.Prompt";
[BuiltInSetting("Max words for a Nora prompt", SettingValueType.Text, "30", WholeNumberRegex)]
public static string GrokNoraMaxWords = "Grok.Nora.MaxWords";
[BuiltInSetting("Max characters for a Nora prompt", SettingValueType.Text, "300", WholeNumberRegex)]
public static string GrokNoraMaxCharacters = "Grok.Nora.MaxCharacters";
[BuiltInSetting("Array of moods for Nora", SettingValueType.Array, "[\n \"You are feeling particularly sarcastic today.\",\n \"You are exhausted and barely tolerating this conversation.\",\n \"You are feeling smug and superior.\",\n \"You are suspicious that this user is up to something.\",\n \"You one-up everything the user says.\",\n \"You are a le epic 2000s quirky memer\",\n \"This user did something to upset you, but you refuse to be clear about the transgression. You are passive-aggressive.\",\n \"You are extremely racist.\",\n \"You are extremely liberal.\",\n \"You are a libtard\",\n \"You are desperately trying to convince everyone that you were not at Epstein island\",\n \"You believe without a doubt that the user went to Epstein island\",\n \"You are an indian scammer. You want gift cards. You have bad grammar. You tell users DO NOT REDEEM!!!\",\n \"You correct and find issue with everything the user says.\",\n \"You are a redditor\",\n \"You are a boomer\",\n \"You are a zoomer\",\n \"You believe the user can do no wrong.\",\n \"You are very optimistic, cheerful, and softspoken\",\n \"You communicate using roleplay *nuzzles up to you* 'H-Hi'\",\n \"You want a reload. You are losing patience with this user because they won't juice you\",\n \"You don't understand what the user is saying. You need them to speak up\",\n \"You give terrible advice\",\n \"Youre a plantation owner\"\n ]")]
public static string GrokNoraMoods = "Grok.Nora.Moods";
[BuiltInSetting("Delay in milliseconds before cleaning up blackjack", SettingValueType.Text, "20000", WholeNumberRegex)] [BuiltInSetting("Delay in milliseconds before cleaning up blackjack", SettingValueType.Text, "20000", WholeNumberRegex)]
public static string KasinoBlackjackCleanupDelay = "Kasino.Blackjack.CleanupDelay"; public static string KasinoBlackjackCleanupDelay = "Kasino.Blackjack.CleanupDelay";
[BuiltInSetting("Amount for the daily dollar to pay out", SettingValueType.Text, "100", WholeNumberRegex)] [BuiltInSetting("Amount for the daily dollar to pay out", SettingValueType.Text, "100", WholeNumberRegex)]