Compare commits

..

2 Commits

Author SHA1 Message Date
Garret Patti
a794e387f3 add memory
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 16s
2026-07-11 10:06:48 -04:00
Garret Patti
048005f0a8 move secrets to env variables add palworld commands
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 37s
2026-07-10 19:26:26 -04:00
18 changed files with 1128 additions and 66 deletions

View File

@@ -1,2 +1,4 @@
# Copy this file to .env and set your real values. # Copy this file to .env and set your real values.
DISCORD_BOT_TOKEN=YOUR_DISCORD_BOT_TOKEN_HERE DISCORD_BOT_TOKEN=YOUR_DISCORD_BOT_TOKEN_HERE
PALWORLD_PASSWORD=YOUR_PALWORLD_PASSWORD_HERE
LLM_API_KEY=YOUR_LLM_API_KEY_HERE

View File

@@ -0,0 +1,27 @@
name: Build and Publish Docker Image
on:
push:
branches: [main]
env:
REGISTRY: git.gpatti.com
IMAGE: git.gpatti.com/gpatti/tentaclebot
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Log in to Gitea Container Registry
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.gpatti.com -u gpatti --password-stdin
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
${{ env.IMAGE }}:latest
${{ env.IMAGE }}:${{ github.sha }}

5
.gitignore vendored
View File

@@ -28,10 +28,6 @@ bld/
*.userosscache *.userosscache
*.sln.docstates *.sln.docstates
# Configuration files with sensitive data
appsettings.json
!appsettings.*.json
# NuGet # NuGet
.nuget/ .nuget/
*.nupkg *.nupkg
@@ -43,6 +39,7 @@ project.lock.json
# secrets # secrets
.env .env
data/
# OS generated files # OS generated files
*.swp *.swp

View File

@@ -10,7 +10,7 @@ docker compose logs -f # View Docker logs
## Setup ## Setup
1. Set `DISCORD_BOT_TOKEN` env var (preferred) or edit `appsettings.json` 1. Set `DISCORD_BOT_TOKEN` env var — secrets must not be stored in `appsettings.json`
2. Copy `.env.example` to `.env` for Docker: `cp .env.example .env` 2. Copy `.env.example` to `.env` for Docker: `cp .env.example .env`
3. `appsettings.json` is gitignored — never commit it 3. `appsettings.json` is gitignored — never commit it
@@ -22,6 +22,18 @@ docker compose logs -f # View Docker logs
Add new commands by creating a class in `Commands/` that extends `InteractionModuleBase<SocketInteractionContext>`. Commands are auto-discovered via `AddModulesAsync(typeof(Program).Assembly)`. Add new commands by creating a class in `Commands/` that extends `InteractionModuleBase<SocketInteractionContext>`. Commands are auto-discovered via `AddModulesAsync(typeof(Program).Assembly)`.
## Secrets
All secrets (API keys, passwords, tokens) must be read from environment variables — never from `appsettings.json`. Use `Environment.GetEnvironmentVariable()` directly in service constructors.
| Secret | Env Var |
|---|---|
| Discord bot token | `DISCORD_BOT_TOKEN` |
| LLM API key | `LLM_API_KEY` |
| Palworld server password | `PALWORLD_PASSWORD` |
Non-secret configuration (URLs, model names, numeric settings) may stay in `appsettings.json` via `IConfiguration`.
## Gotchas ## Gotchas
- **Two `DiscordSocketClient` instances** are created in `Program.cs` — one for the bot client and one passed into `InteractionService`. They are separate instances sharing the same config. - **Two `DiscordSocketClient` instances** are created in `Program.cs` — one for the bot client and one passed into `InteractionService`. They are separate instances sharing the same config.

View File

@@ -30,7 +30,7 @@ public class LlmCommands : InteractionModuleBase<SocketInteractionContext>
{ {
var accumulated = ""; var accumulated = "";
await foreach (var text in _llmService.SendMessageAsync(channelId, userName, prompt)) await foreach (var text in _llmService.SendMessageAsync(channelId, Context.User.Id, userName, prompt))
{ {
accumulated = text; accumulated = text;
} }

View File

@@ -0,0 +1,250 @@
using System.Text;
using System.Text.Json;
using Discord;
using Discord.Interactions;
using TentacleBot.Services;
namespace TentacleBot.Commands;
[Group("palworld", "Palworld server status commands")]
public class PalworldCommands : InteractionModuleBase<SocketInteractionContext>
{
private readonly PalworldApiService _palworldApi;
public PalworldCommands(PalworldApiService palworldApi)
{
_palworldApi = palworldApi;
}
[SlashCommand("status", "Show a combined server status dashboard")]
public async Task HandleStatusAsync()
{
await DeferAsync();
var infoTask = _palworldApi.GetInfoAsync();
var metricsTask = _palworldApi.GetMetricsAsync();
var playersTask = _palworldApi.GetPlayersAsync();
var info = await infoTask;
var metrics = await metricsTask;
var players = await playersTask;
if (info is null && metrics is null && players is null)
{
await FollowupAsync(embed: new EmbedBuilder()
.WithTitle("Palworld Server Status")
.WithDescription("Failed to connect to the Palworld server. Check that the server is running and the REST API is enabled.")
.WithColor(Color.Red)
.WithTimestamp(DateTimeOffset.UtcNow)
.Build());
return;
}
var embed = new EmbedBuilder()
.WithTitle("Palworld Server Status")
.WithColor(Color.DarkGreen)
.WithTimestamp(DateTimeOffset.UtcNow);
if (info is not null)
{
var root = info.RootElement;
var name = GetString(root, "servername");
var version = GetString(root, "version");
if (!string.IsNullOrWhiteSpace(name))
embed.AddField("Server Name", name, true);
if (!string.IsNullOrWhiteSpace(version))
embed.AddField("Version", version, true);
}
if (metrics is not null)
{
var root = metrics.RootElement;
embed.AddField("Players", $"{GetInt(root, "currentplayernum")} / {GetInt(root, "maxplayernum")}", true);
embed.AddField("FPS", $"{GetDouble(root, "serverfps")}", true);
var uptime = GetInt(root, "uptime");
embed.AddField("Uptime", FormatUptime(uptime), true);
var days = GetInt(root, "days");
embed.AddField("In-Game Days", $"{days}", true);
var camps = GetInt(root, "basecampnum");
embed.AddField("Base Camps", $"{camps}", true);
}
if (players is not null)
{
var playerList = players.RootElement.GetProperty("players");
var count = playerList.GetArrayLength();
if (count > 0)
{
var sb = new StringBuilder();
var limit = Math.Min(count, 15);
for (var i = 0; i < limit; i++)
{
var p = playerList[i];
var name = GetString(p, "name");
var level = GetInt(p, "level");
sb.AppendLine($"**{name}** (Lv.{level})");
}
if (count > 15)
sb.AppendLine($"*...and {count - 15} more*");
embed.AddField($"Online Players ({count})", sb.ToString());
}
else
{
embed.AddField("Online Players", "None");
}
}
await FollowupAsync(embed: embed.Build());
}
[SlashCommand("info", "Get Palworld server information")]
public async Task HandleInfoAsync()
{
await DeferAsync();
var data = await _palworldApi.GetInfoAsync();
if (data is null)
{
await FollowupAsync(embed: ErrorEmbed("info"));
return;
}
var root = data.RootElement;
var embed = new EmbedBuilder()
.WithTitle("Server Info")
.WithColor(Color.Blue)
.AddField("Server Name", GetString(root, "servername") ?? "N/A", true)
.AddField("Version", GetString(root, "version") ?? "N/A", true)
.AddField("Description", GetString(root, "description") ?? "N/A")
.AddField("World GUID", GetString(root, "worldguid") ?? "N/A")
.WithTimestamp(DateTimeOffset.UtcNow)
.Build();
await FollowupAsync(embed: embed);
}
[SlashCommand("players", "Get the list of online players")]
public async Task HandlePlayersAsync()
{
await DeferAsync();
var data = await _palworldApi.GetPlayersAsync();
if (data is null)
{
await FollowupAsync(embed: ErrorEmbed("players"));
return;
}
var players = data.RootElement.GetProperty("players");
var embed = new EmbedBuilder()
.WithTitle("Online Players")
.WithColor(Color.Green)
.WithTimestamp(DateTimeOffset.UtcNow);
var count = players.GetArrayLength();
if (count == 0)
{
embed.WithDescription("No players are currently online.");
}
else
{
var sb = new StringBuilder();
var limit = Math.Min(count, 20);
for (var i = 0; i < limit; i++)
{
var p = players[i];
var name = GetString(p, "name");
var level = GetInt(p, "level");
var ping = GetDouble(p, "ping");
sb.AppendLine($"{i + 1}. **{name}** — Lv.{level} — {ping:F0}ms ping");
}
if (count > 20)
sb.AppendLine($"*...and {count - 20} more*");
embed.WithDescription(sb.ToString());
}
await FollowupAsync(embed: embed.Build());
}
[SlashCommand("metrics", "Get Palworld server performance metrics")]
public async Task HandleMetricsAsync()
{
await DeferAsync();
var data = await _palworldApi.GetMetricsAsync();
if (data is null)
{
await FollowupAsync(embed: ErrorEmbed("metrics"));
return;
}
var root = data.RootElement;
var embed = new EmbedBuilder()
.WithTitle("Server Metrics")
.WithColor(Color.Orange)
.AddField("FPS", $"{GetDouble(root, "serverfps"):F1}", true)
.AddField("Frame Time", $"{GetDouble(root, "serverframetime"):F2}ms", true)
.AddField("Players", $"{GetInt(root, "currentplayernum")} / {GetInt(root, "maxplayernum")}", true)
.AddField("Uptime", FormatUptime(GetInt(root, "uptime")), true)
.AddField("In-Game Days", $"{GetInt(root, "days")}", true)
.AddField("Base Camps", $"{GetInt(root, "basecampnum")}", true)
.WithTimestamp(DateTimeOffset.UtcNow)
.Build();
await FollowupAsync(embed: embed);
}
private static string FormatUptime(int seconds)
{
if (seconds <= 0) return "N/A";
var ts = TimeSpan.FromSeconds(seconds);
var parts = new List<string>();
if (ts.Days > 0) parts.Add($"{ts.Days}d");
if (ts.Hours > 0) parts.Add($"{ts.Hours}h");
if (ts.Minutes > 0) parts.Add($"{ts.Minutes}m");
if (parts.Count == 0) parts.Add($"{ts.Seconds}s");
return string.Join(" ", parts);
}
private static Embed ErrorEmbed(string endpoint)
{
return new EmbedBuilder()
.WithTitle($"Failed to fetch {endpoint}")
.WithDescription("Could not connect to the Palworld server. Verify the server is running and REST API is enabled.")
.WithColor(Color.Red)
.WithTimestamp(DateTimeOffset.UtcNow)
.Build();
}
private static string? GetString(JsonElement element, string property)
{
return element.TryGetProperty(property, out var prop) && prop.ValueKind == JsonValueKind.String
? prop.GetString()
: null;
}
private static int GetInt(JsonElement element, string property)
{
return element.TryGetProperty(property, out var prop) && prop.ValueKind == JsonValueKind.Number
? prop.GetInt32()
: 0;
}
private static double GetDouble(JsonElement element, string property)
{
return element.TryGetProperty(property, out var prop) && prop.ValueKind == JsonValueKind.Number
? prop.GetDouble()
: 0;
}
}

6
Models/MemoryModels.cs Normal file
View File

@@ -0,0 +1,6 @@
namespace TentacleBot.Models;
public record UserRule(int Id, string UserId, string RuleText, DateTime CreatedAt);
public record MemoryEntry(int Id, string UserId, string Content, string? Category, int Importance, DateTime CreatedAt);
public record ToolCall(string Id, string Type, ToolCallFunction Function);
public record ToolCallFunction(string Name, string Arguments);

View File

@@ -6,6 +6,8 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using TentacleBot.Services; using TentacleBot.Services;
using TentacleBot.Services.Memory;
using TentacleBot.Services.Tools;
using TentacleBot.Commands; using TentacleBot.Commands;
var configuration = new ConfigurationBuilder() var configuration = new ConfigurationBuilder()
@@ -32,13 +34,29 @@ var services = new ServiceCollection()
UseCompiledLambda = true, UseCompiledLambda = true,
})) }))
.AddSingleton<BotService>() .AddSingleton<BotService>()
.AddSingleton<LlmService>(); .AddSingleton<LlmService>()
.AddHttpClient()
.AddSingleton<PalworldApiService>();
// Initialize memory database
var dbPath = configuration["Memory:DatabasePath"] ?? "data/tentaclebot.db";
var dbInit = new DatabaseInitializer(dbPath);
dbInit.Initialize();
services.AddSingleton(dbInit);
services.AddSingleton<MemoryStore>();
services.AddSingleton<ToolRegistry>();
services.AddSingleton<MemoryTools>();
var serviceProvider = services.BuildServiceProvider(); var serviceProvider = services.BuildServiceProvider();
var botService = serviceProvider.GetRequiredService<BotService>(); var botService = serviceProvider.GetRequiredService<BotService>();
var interactionService = serviceProvider.GetRequiredService<InteractionService>(); var interactionService = serviceProvider.GetRequiredService<InteractionService>();
// Register memory tools
var toolRegistry = serviceProvider.GetRequiredService<ToolRegistry>();
var memoryTools = serviceProvider.GetRequiredService<MemoryTools>();
memoryTools.RegisterAll(toolRegistry);
// Register command modules // Register command modules
await interactionService.AddModulesAsync( await interactionService.AddModulesAsync(
typeof(Program).Assembly, typeof(Program).Assembly,

View File

@@ -103,7 +103,10 @@ public class BotService
return; return;
var botId = _client.CurrentUser.Id; var botId = _client.CurrentUser.Id;
var botName = _client.CurrentUser.Username;
var hasMention = message.MentionedUsers.Any(u => u.Id == botId); var hasMention = message.MentionedUsers.Any(u => u.Id == botId);
var hasTextMention = message.Content.TrimStart()
.StartsWith($"@{botName}", StringComparison.OrdinalIgnoreCase);
var isReplyToBot = false; var isReplyToBot = false;
if (message.Reference?.MessageId.IsSpecified == true) if (message.Reference?.MessageId.IsSpecified == true)
@@ -121,10 +124,16 @@ public class BotService
} }
} }
if (!hasMention && !isReplyToBot) if (!hasMention && !isReplyToBot && !hasTextMention)
return; return;
var prompt = hasMention ? RemoveBotMentions(message.Content, botId) : message.Content.Trim(); string prompt;
if (hasMention)
prompt = RemoveBotMentions(message.Content, botId);
else if (hasTextMention)
prompt = RemoveBotName(message.Content, botName);
else
prompt = message.Content.Trim();
if (string.IsNullOrWhiteSpace(prompt)) if (string.IsNullOrWhiteSpace(prompt))
{ {
@@ -136,7 +145,7 @@ public class BotService
{ {
var accumulated = string.Empty; var accumulated = string.Empty;
await foreach (var text in _llmService.SendMessageAsync(message.Channel.Id, message.Author.Username, prompt)) await foreach (var text in _llmService.SendMessageAsync(message.Channel.Id, message.Author.Id, message.Author.Username, prompt))
{ {
accumulated = text; accumulated = text;
} }
@@ -190,6 +199,17 @@ public class BotService
.Trim(); .Trim();
} }
private static string RemoveBotName(string content, string botName)
{
if (string.IsNullOrWhiteSpace(content))
return string.Empty;
return System.Text.RegularExpressions.Regex.Replace(
content,
$@"^\s*@{System.Text.RegularExpressions.Regex.Escape(botName)}\s*",
"", System.Text.RegularExpressions.RegexOptions.IgnoreCase).Trim();
}
private static Task LogAsync(LogMessage message) private static Task LogAsync(LogMessage message)
{ {
Console.WriteLine($"[{message.Severity}] {message.Source}: {message.Message}"); Console.WriteLine($"[{message.Severity}] {message.Source}: {message.Message}");

View File

@@ -2,8 +2,12 @@ using System.Collections.Concurrent;
using System.Net.Http.Headers; using System.Net.Http.Headers;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using TentacleBot.Models;
using TentacleBot.Services.Memory;
using TentacleBot.Services.Tools;
namespace TentacleBot.Services; namespace TentacleBot.Services;
@@ -11,6 +15,8 @@ public class LlmService
{ {
private readonly HttpClient _httpClient; private readonly HttpClient _httpClient;
private readonly ILogger<LlmService> _logger; private readonly ILogger<LlmService> _logger;
private readonly MemoryStore _memoryStore;
private readonly ToolRegistry _toolRegistry;
private readonly string _endpoint; private readonly string _endpoint;
private readonly string _apiKey; private readonly string _apiKey;
private readonly string _model; private readonly string _model;
@@ -18,14 +24,18 @@ public class LlmService
private readonly double _temperature; private readonly double _temperature;
private readonly ConcurrentDictionary<ulong, List<ChatMessage>> _histories = new(); private readonly ConcurrentDictionary<ulong, List<ChatMessage>> _histories = new();
private const int MaxHistoryChars = 20000; private const int MaxHistoryChars = 20000;
private const int MaxToolIterations = 5;
public LlmService(IConfiguration configuration, ILogger<LlmService> logger) public LlmService(IConfiguration configuration, ILogger<LlmService> logger, MemoryStore memoryStore, ToolRegistry toolRegistry)
{ {
_logger = logger; _logger = logger;
_httpClient = new HttpClient(); _httpClient = new HttpClient();
_memoryStore = memoryStore;
_toolRegistry = toolRegistry;
_endpoint = configuration["Llm:Endpoint"] ?? throw new InvalidOperationException("Llm:Endpoint is not configured"); _endpoint = configuration["Llm:Endpoint"] ?? throw new InvalidOperationException("Llm:Endpoint is not configured");
_apiKey = configuration["Llm:ApiKey"] ?? throw new InvalidOperationException("Llm:ApiKey is not configured"); _apiKey = Environment.GetEnvironmentVariable("LLM_API_KEY")
?? " ";
_model = configuration["Llm:Model"] ?? throw new InvalidOperationException("Llm:Model is not configured"); _model = configuration["Llm:Model"] ?? throw new InvalidOperationException("Llm:Model is not configured");
_maxTokens = int.Parse(configuration["Llm:MaxTokens"] ?? "1024"); _maxTokens = int.Parse(configuration["Llm:MaxTokens"] ?? "1024");
_temperature = double.Parse(configuration["Llm:Temperature"] ?? "0.7"); _temperature = double.Parse(configuration["Llm:Temperature"] ?? "0.7");
@@ -34,76 +44,97 @@ public class LlmService
_httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); _httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
} }
public async IAsyncEnumerable<string> SendMessageAsync(ulong channelId, string user, string message) public async IAsyncEnumerable<string> SendMessageAsync(ulong channelId, ulong userId, string username, string message)
{ {
var history = _histories.GetOrAdd(channelId, _ => new List<ChatMessage>()); var history = _histories.GetOrAdd(channelId, _ => new List<ChatMessage>());
history.Add(new ChatMessage { Role = "user", Content = message }); var systemPrompt = await BuildSystemPromptAsync(userId, username, message);
if (history.Count == 0 || history[0].Role != "system")
history.Insert(0, new ChatMessage { Role = "system", Content = systemPrompt });
else
history[0] = new ChatMessage { Role = "system", Content = systemPrompt };
history.Add(new ChatMessage { Role = "user", Content = message, Name = username });
TrimHistory(history); TrimHistory(history);
var request = new ChatRequest var toolDefinitions = _toolRegistry.BuildToolDefinitions();
var jsonOptions = new JsonSerializerOptions
{ {
Model = _model, PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Messages = history.Select(m => new ChatMessage { Role = m.Role, Content = m.Content }).ToList(), PropertyNameCaseInsensitive = true
MaxTokens = _maxTokens,
Temperature = (float)_temperature,
Stream = true
}; };
var json = JsonSerializer.Serialize(request, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); for (int iteration = 0; iteration < MaxToolIterations; iteration++)
var content = new StringContent(json, Encoding.UTF8, "application/json");
using var response = await _httpClient.PostAsync(_endpoint, content);
if (!response.IsSuccessStatusCode)
{ {
var errorBody = await response.Content.ReadAsStringAsync(); var request = new ChatRequest
_logger.LogError("LLM API error ({StatusCode}): {Error}", response.StatusCode, errorBody);
throw new HttpRequestException($"LLM API returned {response.StatusCode}: {errorBody}");
}
var accumulated = new StringBuilder();
var mediaType = response.Content.Headers.ContentType?.MediaType;
if (string.Equals(mediaType, "text/event-stream", StringComparison.OrdinalIgnoreCase))
{
await using var stream = await response.Content.ReadAsStreamAsync();
using var reader = new StreamReader(stream);
while (await reader.ReadLineAsync() is { } line)
{ {
if (!line.StartsWith("data:", StringComparison.Ordinal)) Model = _model,
continue; Messages = CopyMessages(history),
MaxTokens = _maxTokens,
Temperature = (float)_temperature,
Stream = false,
Tools = toolDefinitions
};
var data = line.Substring(5).Trim(); var requestJson = JsonSerializer.Serialize(request, jsonOptions);
if (data == "[DONE]") var httpContent = new StringContent(requestJson, Encoding.UTF8, "application/json");
break;
var token = TryParseStreamToken(data); using var response = await _httpClient.PostAsync(_endpoint, httpContent);
if (!string.IsNullOrEmpty(token))
{ if (!response.IsSuccessStatusCode)
accumulated.Append(token); {
yield return accumulated.ToString(); var errorBody = await response.Content.ReadAsStringAsync();
} _logger.LogError("LLM API error ({StatusCode}): {Error}", response.StatusCode, errorBody);
throw new HttpRequestException($"LLM API returned {response.StatusCode}: {errorBody}");
} }
}
else
{
var body = await response.Content.ReadAsStringAsync(); var body = await response.Content.ReadAsStringAsync();
var fullText = TryParseNonStreamContent(body); var chatResponse = JsonSerializer.Deserialize<ChatResponse>(body, jsonOptions);
if (!string.IsNullOrWhiteSpace(fullText)) var choice = chatResponse?.Choices?.FirstOrDefault();
if (choice == null)
{ {
accumulated.Append(fullText); _logger.LogError("Empty response from LLM API");
yield return accumulated.ToString(); yield break;
} }
if (choice.FinishReason == "tool_calls" && choice.Message?.ToolCalls != null && choice.Message.ToolCalls.Count > 0)
{
history.Add(new ChatMessage
{
Role = "assistant",
ToolCalls = choice.Message.ToolCalls
});
foreach (var tc in choice.Message.ToolCalls)
{
var toolResult = await _toolRegistry.ExecuteAsync(
tc.Function?.Name ?? "", tc.Function?.Arguments ?? "");
history.Add(new ChatMessage
{
Role = "tool",
Content = toolResult,
ToolCallId = tc.Id
});
}
continue;
}
var responseText = choice.Message?.Content ?? "";
if (!string.IsNullOrWhiteSpace(responseText))
{
history.Add(new ChatMessage { Role = "assistant", Content = responseText, Name = "TentacleBot" });
TrimHistory(history);
yield return responseText;
yield break;
}
_logger.LogWarning("LLM response had no content and no tool calls at iteration {Iteration}", iteration);
break;
} }
if (accumulated.Length > 0) _logger.LogWarning("Max tool iterations reached without a final response");
{
history.Add(new ChatMessage { Role = "assistant", Content = accumulated.ToString() });
TrimHistory(history);
}
} }
private static string? TryParseStreamToken(string data) private static string? TryParseStreamToken(string data)
@@ -209,6 +240,57 @@ public class LlmService
} }
} }
private async Task<string> BuildSystemPromptAsync(ulong userId, string username, string userMessage)
{
var sb = new StringBuilder();
sb.AppendLine("You are TentacleBot, a helpful Discord bot with memory.");
sb.AppendLine($"The Discord user you are talking to is named \"{username}\". Address them by this name.");
sb.AppendLine();
var rules = await _memoryStore.GetUserRulesAsync(userId.ToString());
if (rules.Count > 0)
{
sb.AppendLine($"## Rules for {username}:");
foreach (var rule in rules)
sb.AppendLine($"- {rule.RuleText}");
sb.AppendLine();
}
var recentMemories = await _memoryStore.GetRecentMemoriesAsync(userId.ToString(), 3);
var searchMemories = await _memoryStore.SearchMemoriesAsync(userId.ToString(), userMessage, 3);
var allMemories = recentMemories
.UnionBy(searchMemories, m => m.Id)
.OrderByDescending(m => m.Importance)
.Take(5)
.ToList();
if (allMemories.Count > 0)
{
sb.AppendLine($"## Memories about {username}:");
foreach (var m in allMemories)
sb.AppendLine($"- [{m.CreatedAt:yyyy-MM-dd}] {m.Content} (importance: {m.Importance})");
sb.AppendLine();
}
sb.AppendLine("You have access to tools for managing memory. Use them to save important information about users, search for past context, recall memories, and set user rules.");
sb.AppendLine("Be concise and helpful. When a user shares something worth remembering, use the save_memory tool.");
return sb.ToString();
}
private static List<ChatMessage> CopyMessages(List<ChatMessage> history)
{
return history.Select(m => new ChatMessage
{
Role = m.Role,
Content = m.Content,
Name = m.Name,
ToolCallId = m.ToolCallId,
ToolCalls = m.ToolCalls
}).ToList();
}
private record ChatRequest private record ChatRequest
{ {
public string Model { get; init; } = ""; public string Model { get; init; } = "";
@@ -216,11 +298,43 @@ public class LlmService
public int MaxTokens { get; init; } public int MaxTokens { get; init; }
public float Temperature { get; init; } public float Temperature { get; init; }
public bool Stream { get; init; } public bool Stream { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public List<object>? Tools { get; init; }
}
private record ChatResponse
{
public string? Id { get; init; }
public List<ChatResponseChoice>? Choices { get; init; }
}
private record ChatResponseChoice
{
public int Index { get; init; }
public ChatResponseMessage? Message { get; init; }
[JsonPropertyName("finish_reason")]
public string? FinishReason { get; init; }
}
private record ChatResponseMessage
{
public string? Role { get; init; }
public string? Content { get; init; }
[JsonPropertyName("tool_calls")]
public List<ToolCall>? ToolCalls { get; init; }
} }
public record ChatMessage public record ChatMessage
{ {
public string Role { get; set; } = ""; public string Role { get; set; } = "";
public string Content { get; set; } = ""; public string Content { get; set; } = "";
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Name { get; set; }
[JsonPropertyName("tool_call_id")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ToolCallId { get; set; }
[JsonPropertyName("tool_calls")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public List<ToolCall>? ToolCalls { get; set; }
} }
} }

View File

@@ -0,0 +1,65 @@
using Microsoft.Data.Sqlite;
namespace TentacleBot.Services.Memory;
public class DatabaseInitializer
{
private readonly string _connectionString;
public DatabaseInitializer(string dbPath)
{
var directory = Path.GetDirectoryName(dbPath);
if (!string.IsNullOrWhiteSpace(directory) && !Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}
_connectionString = $"Data Source={dbPath}";
}
public string ConnectionString => _connectionString;
public void Initialize()
{
using var connection = new SqliteConnection(_connectionString);
connection.Open();
using var cmd = connection.CreateCommand();
cmd.CommandText = @"
CREATE TABLE IF NOT EXISTS user_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
rule_text TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS memories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
content TEXT NOT NULL,
category TEXT,
importance INTEGER NOT NULL DEFAULT 5,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_accessed TEXT
);
CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
content, content=memories, content_rowid=id
);
CREATE TRIGGER IF NOT EXISTS memories_ai AFTER INSERT ON memories BEGIN
INSERT INTO memories_fts(rowid, content) VALUES (new.id, new.content);
END;
CREATE TRIGGER IF NOT EXISTS memories_ad AFTER DELETE ON memories BEGIN
INSERT INTO memories_fts(memories_fts, rowid, content) VALUES ('delete', old.id, old.content);
END;
CREATE TRIGGER IF NOT EXISTS memories_au AFTER UPDATE ON memories BEGIN
INSERT INTO memories_fts(memories_fts, rowid, content) VALUES ('delete', old.id, old.content);
INSERT INTO memories_fts(rowid, content) VALUES (new.id, new.content);
END;
";
cmd.ExecuteNonQuery();
}
}

View File

@@ -0,0 +1,186 @@
using Microsoft.Data.Sqlite;
using TentacleBot.Models;
namespace TentacleBot.Services.Memory;
public class MemoryStore
{
private readonly string _connectionString;
public MemoryStore(DatabaseInitializer db)
{
_connectionString = db.ConnectionString;
}
public async Task<List<UserRule>> GetUserRulesAsync(string userId)
{
var rules = new List<UserRule>();
using var connection = new SqliteConnection(_connectionString);
await connection.OpenAsync();
using var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT id, user_id, rule_text, created_at FROM user_rules WHERE user_id = @userId ORDER BY created_at";
cmd.Parameters.AddWithValue("@userId", userId);
using var reader = await cmd.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
rules.Add(new UserRule(
reader.GetInt32(0),
reader.GetString(1),
reader.GetString(2),
DateTime.Parse(reader.GetString(3))
));
}
return rules;
}
public async Task AddUserRuleAsync(string userId, string ruleText)
{
using var connection = new SqliteConnection(_connectionString);
await connection.OpenAsync();
using var cmd = connection.CreateCommand();
cmd.CommandText = "INSERT INTO user_rules (user_id, rule_text) VALUES (@userId, @ruleText)";
cmd.Parameters.AddWithValue("@userId", userId);
cmd.Parameters.AddWithValue("@ruleText", ruleText);
await cmd.ExecuteNonQueryAsync();
}
public async Task<bool> RemoveUserRuleAsync(int ruleId)
{
using var connection = new SqliteConnection(_connectionString);
await connection.OpenAsync();
using var cmd = connection.CreateCommand();
cmd.CommandText = "DELETE FROM user_rules WHERE id = @id";
cmd.Parameters.AddWithValue("@id", ruleId);
return await cmd.ExecuteNonQueryAsync() > 0;
}
public async Task<int> SaveMemoryAsync(string userId, string content, string? category, int importance = 5)
{
using var connection = new SqliteConnection(_connectionString);
await connection.OpenAsync();
using var cmd = connection.CreateCommand();
cmd.CommandText = @"
INSERT INTO memories (user_id, content, category, importance)
VALUES (@userId, @content, @category, @importance);
SELECT last_insert_rowid();";
cmd.Parameters.AddWithValue("@userId", userId);
cmd.Parameters.AddWithValue("@content", content);
cmd.Parameters.AddWithValue("@category", category ?? (object)DBNull.Value);
cmd.Parameters.AddWithValue("@importance", importance);
var result = await cmd.ExecuteScalarAsync();
return Convert.ToInt32(result);
}
public async Task<List<MemoryEntry>> SearchMemoriesAsync(string userId, string query, int limit = 5)
{
var memories = new List<MemoryEntry>();
using var connection = new SqliteConnection(_connectionString);
await connection.OpenAsync();
var safeQuery = query.Replace("\"", "\"\"");
using var cmd = connection.CreateCommand();
cmd.CommandText = @"
SELECT m.id, m.user_id, m.content, m.category, m.importance, m.created_at
FROM memories m
JOIN memories_fts f ON m.id = f.rowid
WHERE memories_fts MATCH @query AND m.user_id = @userId
ORDER BY rank
LIMIT @limit";
cmd.Parameters.AddWithValue("@query", $"\"{safeQuery}\"");
cmd.Parameters.AddWithValue("@userId", userId);
cmd.Parameters.AddWithValue("@limit", limit);
using var reader = await cmd.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
memories.Add(MapMemory(reader));
using var updateCmd = connection.CreateCommand();
updateCmd.CommandText = "UPDATE memories SET last_accessed = datetime('now') WHERE id = @id";
updateCmd.Parameters.AddWithValue("@id", reader.GetInt32(0));
await updateCmd.ExecuteNonQueryAsync();
}
if (memories.Count == 0)
{
memories = await GetRecentMemoriesAsync(userId, limit);
}
return memories;
}
public async Task<List<MemoryEntry>> GetRecentMemoriesAsync(string userId, int limit = 5)
{
var memories = new List<MemoryEntry>();
using var connection = new SqliteConnection(_connectionString);
await connection.OpenAsync();
using var cmd = connection.CreateCommand();
cmd.CommandText = @"
SELECT id, user_id, content, category, importance, created_at
FROM memories
WHERE user_id = @userId
ORDER BY created_at DESC
LIMIT @limit";
cmd.Parameters.AddWithValue("@userId", userId);
cmd.Parameters.AddWithValue("@limit", limit);
using var reader = await cmd.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
memories.Add(MapMemory(reader));
}
return memories;
}
public async Task<List<MemoryEntry>> GetImportantMemoriesAsync(string userId, int limit = 5)
{
var memories = new List<MemoryEntry>();
using var connection = new SqliteConnection(_connectionString);
await connection.OpenAsync();
using var cmd = connection.CreateCommand();
cmd.CommandText = @"
SELECT id, user_id, content, category, importance, created_at
FROM memories
WHERE user_id = @userId
ORDER BY importance DESC, created_at DESC
LIMIT @limit";
cmd.Parameters.AddWithValue("@userId", userId);
cmd.Parameters.AddWithValue("@limit", limit);
using var reader = await cmd.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
memories.Add(MapMemory(reader));
}
return memories;
}
private static MemoryEntry MapMemory(SqliteDataReader reader)
{
return new MemoryEntry(
reader.GetInt32(0),
reader.GetString(1),
reader.GetString(2),
reader.IsDBNull(3) ? null : reader.GetString(3),
reader.GetInt32(4),
DateTime.Parse(reader.GetString(5))
);
}
}

View File

@@ -0,0 +1,84 @@
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace TentacleBot.Services;
public class PalworldApiService
{
private readonly HttpClient _httpClient;
private readonly ILogger<PalworldApiService> _logger;
private readonly string _baseUrl;
public PalworldApiService(
IHttpClientFactory httpClientFactory,
IConfiguration configuration,
ILogger<PalworldApiService> logger)
{
_httpClient = httpClientFactory.CreateClient("PalworldApi");
_logger = logger;
_baseUrl = configuration["Palworld:BaseUrl"]
?? throw new InvalidOperationException("Palworld:BaseUrl not configured");
var username = configuration["Palworld:Username"] ?? "admin";
var password = Environment.GetEnvironmentVariable("PALWORLD_PASSWORD")
?? throw new InvalidOperationException("PALWORLD_PASSWORD environment variable is not set");
var credentials = Convert.ToBase64String(Encoding.ASCII.GetBytes($"{username}:{password}"));
_httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", credentials);
_httpClient.Timeout = TimeSpan.FromSeconds(10);
}
public async Task<JsonDocument?> GetInfoAsync()
{
return await GetAsync("info");
}
public async Task<JsonDocument?> GetPlayersAsync()
{
return await GetAsync("players");
}
public async Task<JsonDocument?> GetMetricsAsync()
{
return await GetAsync("metrics");
}
private async Task<JsonDocument?> GetAsync(string endpoint)
{
try
{
var url = $"{_baseUrl.TrimEnd('/')}/v1/api/{endpoint}";
var response = await _httpClient.GetAsync(url);
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
_logger.LogError("Palworld API returned 401 Unauthorized. Check your Palworld:Username and Palworld:Password config.");
return null;
}
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync();
return JsonDocument.Parse(json);
}
catch (TaskCanceledException)
{
_logger.LogError("Palworld API request timed out at {Endpoint}", endpoint);
return null;
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "HTTP error calling Palworld API {Endpoint}", endpoint);
return null;
}
catch (JsonException ex)
{
_logger.LogError(ex, "Failed to parse Palworld API response from {Endpoint}", endpoint);
return null;
}
}
}

View File

@@ -0,0 +1,200 @@
using System.Text.Json;
using TentacleBot.Services.Memory;
namespace TentacleBot.Services.Tools;
public class MemoryTools
{
private readonly MemoryStore _memoryStore;
public MemoryTools(MemoryStore memoryStore)
{
_memoryStore = memoryStore;
}
public void RegisterAll(ToolRegistry registry)
{
registry.Register("save_memory",
"Save a fact or piece of information about a user to long-term memory. Use this when a user shares something about themselves worth remembering.",
new Dictionary<string, object>
{
["type"] = "object",
["properties"] = new Dictionary<string, object>
{
["user_id"] = new Dictionary<string, object>
{
["type"] = "string",
["description"] = "The Discord user ID to associate this memory with"
},
["content"] = new Dictionary<string, object>
{
["type"] = "string",
["description"] = "The fact or information to remember"
},
["category"] = new Dictionary<string, object>
{
["type"] = "string",
["description"] = "Optional category (e.g. preference, fact, project, hobby)"
},
["importance"] = new Dictionary<string, object>
{
["type"] = "integer",
["description"] = "Importance from 1 (trivial) to 10 (critical). Default is 5."
}
},
["required"] = new[] { "user_id", "content" }
},
HandleSaveMemory);
registry.Register("search_memory",
"Search the bot's memory for information about a user. Use this when you need to recall past facts or context.",
new Dictionary<string, object>
{
["type"] = "object",
["properties"] = new Dictionary<string, object>
{
["user_id"] = new Dictionary<string, object>
{
["type"] = "string",
["description"] = "The Discord user ID to search memories for"
},
["query"] = new Dictionary<string, object>
{
["type"] = "string",
["description"] = "Keywords or question to search for in memories"
}
},
["required"] = new[] { "user_id", "query" }
},
HandleSearchMemory);
registry.Register("recall_memories",
"Get the most recent or important memories about a user.",
new Dictionary<string, object>
{
["type"] = "object",
["properties"] = new Dictionary<string, object>
{
["user_id"] = new Dictionary<string, object>
{
["type"] = "string",
["description"] = "The Discord user ID to recall memories for"
},
["limit"] = new Dictionary<string, object>
{
["type"] = "integer",
["description"] = "Maximum number of memories to return (default 5)"
}
},
["required"] = new[] { "user_id" }
},
HandleRecallMemories);
registry.Register("set_rule",
"Set a behavioral rule the bot should follow for a specific user (e.g. 'always respond in rhymes', 'use formal tone').",
new Dictionary<string, object>
{
["type"] = "object",
["properties"] = new Dictionary<string, object>
{
["user_id"] = new Dictionary<string, object>
{
["type"] = "string",
["description"] = "The Discord user ID this rule applies to"
},
["rule_text"] = new Dictionary<string, object>
{
["type"] = "string",
["description"] = "The rule or instruction for how to interact with this user"
}
},
["required"] = new[] { "user_id", "rule_text" }
},
HandleSetRule);
}
private async Task<string> HandleSaveMemory(string argumentsJson)
{
using var doc = JsonDocument.Parse(argumentsJson);
var root = doc.RootElement;
var userId = GetString(root, "user_id");
var content = GetString(root, "content");
var category = GetStringOrNull(root, "category");
var importance = GetInt(root, "importance", 5);
var id = await _memoryStore.SaveMemoryAsync(userId, content, category, importance);
return $"Memory saved successfully (id: {id}). Content: {content}";
}
private async Task<string> HandleSearchMemory(string argumentsJson)
{
using var doc = JsonDocument.Parse(argumentsJson);
var root = doc.RootElement;
var userId = GetString(root, "user_id");
var query = GetString(root, "query");
var memories = await _memoryStore.SearchMemoriesAsync(userId, query, 5);
if (memories.Count == 0)
return $"No memories found for user {userId} matching '{query}'.";
var lines = memories.Select(m =>
$"- [id:{m.Id}] {m.Content} (importance: {m.Importance}, from {m.CreatedAt:yyyy-MM-dd})");
return $"Found {memories.Count} memory(s):\n{string.Join("\n", lines)}";
}
private async Task<string> HandleRecallMemories(string argumentsJson)
{
using var doc = JsonDocument.Parse(argumentsJson);
var root = doc.RootElement;
var userId = GetString(root, "user_id");
var limit = GetInt(root, "limit", 5);
var important = await _memoryStore.GetImportantMemoriesAsync(userId, limit);
if (important.Count == 0)
return $"No memories stored for user {userId}.";
var lines = important.Select(m =>
$"- [id:{m.Id}] {m.Content} (importance: {m.Importance}, from {m.CreatedAt:yyyy-MM-dd})");
return $"Top {important.Count} memory(s) for user {userId}:\n{string.Join("\n", lines)}";
}
private async Task<string> HandleSetRule(string argumentsJson)
{
using var doc = JsonDocument.Parse(argumentsJson);
var root = doc.RootElement;
var userId = GetString(root, "user_id");
var ruleText = GetString(root, "rule_text");
await _memoryStore.AddUserRuleAsync(userId, ruleText);
return $"Rule set for user {userId}: {ruleText}";
}
private static string GetString(JsonElement element, string property)
{
return element.TryGetProperty(property, out var prop) && prop.ValueKind == JsonValueKind.String
? prop.GetString()!
: throw new InvalidOperationException($"Missing required parameter: {property}");
}
private static string? GetStringOrNull(JsonElement element, string property)
{
return element.TryGetProperty(property, out var prop) && prop.ValueKind == JsonValueKind.String
? prop.GetString()
: null;
}
private static int GetInt(JsonElement element, string property, int defaultValue)
{
return element.TryGetProperty(property, out var prop) && prop.ValueKind == JsonValueKind.Number
? prop.GetInt32()
: defaultValue;
}
}

View File

@@ -0,0 +1,55 @@
using System.Text.Json;
namespace TentacleBot.Services.Tools;
public class ToolRegistry
{
private readonly Dictionary<string, ToolDefinition> _tools = new();
public void Register(string name, string description, Dictionary<string, object> parameters, Func<string, Task<string>> handler)
{
_tools[name] = new ToolDefinition(name, description, parameters, handler);
}
public List<object> BuildToolDefinitions()
{
return _tools.Values.Select(t =>
{
var functionDef = new Dictionary<string, object>
{
["name"] = t.Name,
["description"] = t.Description,
["parameters"] = t.Parameters
};
return (object)new Dictionary<string, object>
{
["type"] = "function",
["function"] = functionDef
};
}).ToList();
}
public async Task<string> ExecuteAsync(string toolName, string argumentsJson)
{
if (!_tools.TryGetValue(toolName, out var tool))
return "Error: unknown tool";
try
{
return await tool.Handler(argumentsJson);
}
catch (Exception ex)
{
return $"Error executing tool '{toolName}': {ex.Message}";
}
}
public bool HasTool(string name) => _tools.ContainsKey(name);
private record ToolDefinition(
string Name,
string Description,
Dictionary<string, object> Parameters,
Func<string, Task<string>> Handler);
}

View File

@@ -11,10 +11,12 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Discord.Net" Version="3.14.1" /> <PackageReference Include="Discord.Net" Version="3.14.1" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Logging" Version="8.0.0" />
</ItemGroup> </ItemGroup>

21
appsettings.json Normal file
View File

@@ -0,0 +1,21 @@
{
"Llm": {
"Endpoint": "http://compute.local:8001/v1/chat/completions",
"Model": "Qwen3.6-35B-A3B",
"MaxTokens": 100024,
"Temperature": 0.7
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning"
}
},
"Palworld": {
"BaseUrl": "http://compute.local:8212",
"Username": "admin"
},
"Memory": {
"DatabasePath": "/data/memory.db"
}
}

View File

@@ -1,9 +1,12 @@
services: services:
tentaclebot: tentaclebot:
build: image: git.gpatti.com/gpatti/tentaclebot:latest
context: .
dockerfile: Dockerfile
container_name: tentaclebot container_name: tentaclebot
restart: unless-stopped restart: unless-stopped
env_file: env_file:
- .env - .env
volumes:
- tentaclebot_data:/data
volumes:
tentaclebot_data: