Compare commits
4 Commits
3064e93011
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a794e387f3 | ||
|
|
048005f0a8 | ||
| 102fb59d84 | |||
|
|
3a37994498 |
@@ -1,2 +1,4 @@
|
||||
# Copy this file to .env and set your real values.
|
||||
DISCORD_BOT_TOKEN=YOUR_DISCORD_BOT_TOKEN_HERE
|
||||
PALWORLD_PASSWORD=YOUR_PALWORLD_PASSWORD_HERE
|
||||
LLM_API_KEY=YOUR_LLM_API_KEY_HERE
|
||||
27
.gitea/workflows/docker-publish.yml
Normal file
27
.gitea/workflows/docker-publish.yml
Normal 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
5
.gitignore
vendored
@@ -28,10 +28,6 @@ bld/
|
||||
*.userosscache
|
||||
*.sln.docstates
|
||||
|
||||
# Configuration files with sensitive data
|
||||
appsettings.json
|
||||
!appsettings.*.json
|
||||
|
||||
# NuGet
|
||||
.nuget/
|
||||
*.nupkg
|
||||
@@ -43,6 +39,7 @@ project.lock.json
|
||||
|
||||
# secrets
|
||||
.env
|
||||
data/
|
||||
|
||||
# OS generated files
|
||||
*.swp
|
||||
|
||||
14
AGENTS.md
14
AGENTS.md
@@ -10,7 +10,7 @@ docker compose logs -f # View Docker logs
|
||||
|
||||
## 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`
|
||||
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)`.
|
||||
|
||||
## 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
|
||||
|
||||
- **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.
|
||||
|
||||
118
Commands/LlmCommands.cs
Normal file
118
Commands/LlmCommands.cs
Normal file
@@ -0,0 +1,118 @@
|
||||
using Discord;
|
||||
using Discord.Interactions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using TentacleBot.Services;
|
||||
|
||||
namespace TentacleBot.Commands;
|
||||
|
||||
[Group("llm", "LLM chat commands")]
|
||||
public class LlmCommands : InteractionModuleBase<SocketInteractionContext>
|
||||
{
|
||||
private readonly LlmService _llmService;
|
||||
private readonly ILogger<LlmCommands> _logger;
|
||||
|
||||
public LlmCommands(LlmService llmService, ILogger<LlmCommands> logger)
|
||||
{
|
||||
_llmService = llmService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[SlashCommand("chat", "Chat with the LLM model")]
|
||||
public async Task HandleChatAsync(
|
||||
[Summary("prompt", "Your message to the LLM")] string prompt)
|
||||
{
|
||||
await DeferAsync();
|
||||
|
||||
var channelId = Context.Channel.Id;
|
||||
var userName = Context.User.Username;
|
||||
|
||||
try
|
||||
{
|
||||
var accumulated = "";
|
||||
|
||||
await foreach (var text in _llmService.SendMessageAsync(channelId, Context.User.Id, userName, prompt))
|
||||
{
|
||||
accumulated = text;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(accumulated))
|
||||
{
|
||||
await FollowupAsync(embed: new EmbedBuilder()
|
||||
.WithTitle("LLM Response")
|
||||
.WithDescription("The model returned an empty response.")
|
||||
.WithColor(Color.Orange)
|
||||
.Build());
|
||||
return;
|
||||
}
|
||||
|
||||
if (accumulated.Length <= 4096)
|
||||
{
|
||||
await FollowupAsync(embed: new EmbedBuilder()
|
||||
.WithTitle($"Response from {Context.Client.CurrentUser?.Username}")
|
||||
.WithDescription(Format.Code(accumulated))
|
||||
.WithColor(Color.DarkTeal)
|
||||
.WithTimestamp(DateTimeOffset.UtcNow)
|
||||
.Build());
|
||||
}
|
||||
else
|
||||
{
|
||||
var truncated = accumulated.Substring(0, 4096);
|
||||
await FollowupAsync(embed: new EmbedBuilder()
|
||||
.WithTitle("LLM Response (truncated)")
|
||||
.WithDescription($"{Format.Code(truncated)}\n\n*Response exceeded Discord's message limit and was truncated.*")
|
||||
.WithColor(Color.Orange)
|
||||
.WithTimestamp(DateTimeOffset.UtcNow)
|
||||
.Build());
|
||||
|
||||
var remaining = accumulated.Substring(4096);
|
||||
var continuation = "Continued...";
|
||||
|
||||
while (remaining.Length > 0)
|
||||
{
|
||||
var chunk = remaining.Length > 4096 ? remaining.Substring(0, 4096) : remaining;
|
||||
continuation += $"\n\n{Format.Code(chunk)}";
|
||||
remaining = remaining.Substring(4096);
|
||||
|
||||
try
|
||||
{
|
||||
await FollowupAsync(continuation, embed: new EmbedBuilder()
|
||||
.WithTitle("LLM Response (continued)")
|
||||
.WithColor(Color.DarkTeal)
|
||||
.Build());
|
||||
continuation = "";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error sending continuation message");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error in LLM chat command");
|
||||
await FollowupAsync(embed: new EmbedBuilder()
|
||||
.WithTitle("Error")
|
||||
.WithDescription("An error occurred while communicating with the LLM.")
|
||||
.WithColor(Color.Red)
|
||||
.WithTimestamp(DateTimeOffset.UtcNow)
|
||||
.Build());
|
||||
}
|
||||
}
|
||||
|
||||
// [SlashCommand("reset", "Reset conversation history for this channel")]
|
||||
// public async Task HandleResetAsync()
|
||||
// {
|
||||
// _llmService.ResetHistory(Context.Channel.Id);
|
||||
|
||||
// var embed = new EmbedBuilder()
|
||||
// .WithTitle("History Reset")
|
||||
// .WithDescription("Conversation history for this channel has been cleared.")
|
||||
// .WithColor(Color.Green)
|
||||
// .WithTimestamp(DateTimeOffset.UtcNow)
|
||||
// .Build();
|
||||
|
||||
// await RespondAsync(embed: embed);
|
||||
// }
|
||||
}
|
||||
250
Commands/PalworldCommands.cs
Normal file
250
Commands/PalworldCommands.cs
Normal 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
6
Models/MemoryModels.cs
Normal 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);
|
||||
21
Program.cs
21
Program.cs
@@ -6,6 +6,8 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using TentacleBot.Services;
|
||||
using TentacleBot.Services.Memory;
|
||||
using TentacleBot.Services.Tools;
|
||||
using TentacleBot.Commands;
|
||||
|
||||
var configuration = new ConfigurationBuilder()
|
||||
@@ -31,13 +33,30 @@ var services = new ServiceCollection()
|
||||
LogLevel = LogSeverity.Info,
|
||||
UseCompiledLambda = true,
|
||||
}))
|
||||
.AddSingleton<BotService>();
|
||||
.AddSingleton<BotService>()
|
||||
.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 botService = serviceProvider.GetRequiredService<BotService>();
|
||||
var interactionService = serviceProvider.GetRequiredService<InteractionService>();
|
||||
|
||||
// Register memory tools
|
||||
var toolRegistry = serviceProvider.GetRequiredService<ToolRegistry>();
|
||||
var memoryTools = serviceProvider.GetRequiredService<MemoryTools>();
|
||||
memoryTools.RegisterAll(toolRegistry);
|
||||
|
||||
// Register command modules
|
||||
await interactionService.AddModulesAsync(
|
||||
typeof(Program).Assembly,
|
||||
|
||||
22
README.md
22
README.md
@@ -80,17 +80,17 @@ docker compose down
|
||||
|
||||
```
|
||||
TentacleBot/
|
||||
<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> Program.cs # Entry point and DI setup
|
||||
<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> Services/
|
||||
<EFBFBD><EFBFBD> <EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> BotService.cs # Core bot service
|
||||
<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> Commands/
|
||||
<EFBFBD><EFBFBD> <EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> GeneralCommands.cs # Example slash commands
|
||||
<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> appsettings.json # Configuration
|
||||
<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> Dockerfile # Container image definition
|
||||
<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> docker-compose.yml # Local compose setup
|
||||
<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> .env.example # Environment variable template
|
||||
<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> TentacleBot.csproj # Project file
|
||||
<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> README.md # This file
|
||||
├── Program.cs # Entry point and DI setup
|
||||
├── Services/
|
||||
│ └── BotService.cs # Core bot service
|
||||
├── Commands/
|
||||
│ └── GeneralCommands.cs # Example slash commands
|
||||
├── appsettings.json # Configuration
|
||||
├── Dockerfile # Container image definition
|
||||
├── docker-compose.yml # Local compose setup
|
||||
├── .env.example # Environment variable template
|
||||
├── TentacleBot.csproj # Project file
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
## Example Commands
|
||||
|
||||
@@ -10,6 +10,7 @@ public class BotService
|
||||
{
|
||||
private readonly DiscordSocketClient _client;
|
||||
private readonly InteractionService _interactionService;
|
||||
private readonly LlmService _llmService;
|
||||
private readonly ILogger<BotService> _logger;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
@@ -17,12 +18,14 @@ public class BotService
|
||||
public BotService(
|
||||
DiscordSocketClient client,
|
||||
InteractionService interactionService,
|
||||
LlmService llmService,
|
||||
ILogger<BotService> logger,
|
||||
IConfiguration configuration,
|
||||
IServiceProvider serviceProvider)
|
||||
{
|
||||
_client = client;
|
||||
_interactionService = interactionService;
|
||||
_llmService = llmService;
|
||||
_logger = logger;
|
||||
_configuration = configuration;
|
||||
_serviceProvider = serviceProvider;
|
||||
@@ -35,6 +38,7 @@ public class BotService
|
||||
|
||||
_client.Ready += ClientReadyAsync;
|
||||
_client.InteractionCreated += InteractionCreatedAsync;
|
||||
_client.MessageReceived += MessageReceivedAsync;
|
||||
|
||||
var token = Environment.GetEnvironmentVariable("DISCORD_BOT_TOKEN");
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
@@ -87,6 +91,125 @@ public class BotService
|
||||
}
|
||||
}
|
||||
|
||||
private async Task MessageReceivedAsync(SocketMessage socketMessage)
|
||||
{
|
||||
if (socketMessage is not SocketUserMessage message)
|
||||
return;
|
||||
|
||||
if (message.Source != MessageSource.User || message.Author.IsBot || message.Author.IsWebhook)
|
||||
return;
|
||||
|
||||
if (_client.CurrentUser is null)
|
||||
return;
|
||||
|
||||
var botId = _client.CurrentUser.Id;
|
||||
var botName = _client.CurrentUser.Username;
|
||||
var hasMention = message.MentionedUsers.Any(u => u.Id == botId);
|
||||
var hasTextMention = message.Content.TrimStart()
|
||||
.StartsWith($"@{botName}", StringComparison.OrdinalIgnoreCase);
|
||||
var isReplyToBot = false;
|
||||
|
||||
if (message.Reference?.MessageId.IsSpecified == true)
|
||||
{
|
||||
try
|
||||
{
|
||||
var referenced = message.ReferencedMessage ??
|
||||
await message.Channel.GetMessageAsync(message.Reference.MessageId.Value);
|
||||
|
||||
isReplyToBot = referenced?.Author.Id == botId;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Unable to resolve referenced message for reply detection.");
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasMention && !isReplyToBot && !hasTextMention)
|
||||
return;
|
||||
|
||||
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))
|
||||
{
|
||||
await message.ReplyAsync("Please include a prompt after mentioning me.");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var accumulated = string.Empty;
|
||||
|
||||
await foreach (var text in _llmService.SendMessageAsync(message.Channel.Id, message.Author.Id, message.Author.Username, prompt))
|
||||
{
|
||||
accumulated = text;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(accumulated))
|
||||
{
|
||||
await message.ReplyAsync("The model returned an empty response.");
|
||||
return;
|
||||
}
|
||||
|
||||
await SendChunkedReplyAsync(message, accumulated);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error handling mention/reply LLM chat in channel {ChannelId}", message.Channel.Id);
|
||||
await message.ReplyAsync("I hit an error while generating a response.");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task SendChunkedReplyAsync(SocketUserMessage message, string response)
|
||||
{
|
||||
const int maxChunkLength = 2000;
|
||||
|
||||
for (var start = 0; start < response.Length; start += maxChunkLength)
|
||||
{
|
||||
var length = Math.Min(maxChunkLength, response.Length - start);
|
||||
var chunk = response.Substring(start, length);
|
||||
|
||||
if (start == 0)
|
||||
{
|
||||
await message.ReplyAsync(chunk);
|
||||
}
|
||||
else
|
||||
{
|
||||
await message.Channel.SendMessageAsync(chunk);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string RemoveBotMentions(string content, ulong botId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
return string.Empty;
|
||||
|
||||
var rawMention = $"<@{botId}>";
|
||||
var nickMention = $"<@!{botId}>";
|
||||
|
||||
return content
|
||||
.Replace(rawMention, string.Empty, StringComparison.Ordinal)
|
||||
.Replace(nickMention, string.Empty, StringComparison.Ordinal)
|
||||
.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)
|
||||
{
|
||||
Console.WriteLine($"[{message.Severity}] {message.Source}: {message.Message}");
|
||||
|
||||
340
Services/LlmService.cs
Normal file
340
Services/LlmService.cs
Normal file
@@ -0,0 +1,340 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using TentacleBot.Models;
|
||||
using TentacleBot.Services.Memory;
|
||||
using TentacleBot.Services.Tools;
|
||||
|
||||
namespace TentacleBot.Services;
|
||||
|
||||
public class LlmService
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<LlmService> _logger;
|
||||
private readonly MemoryStore _memoryStore;
|
||||
private readonly ToolRegistry _toolRegistry;
|
||||
private readonly string _endpoint;
|
||||
private readonly string _apiKey;
|
||||
private readonly string _model;
|
||||
private readonly int _maxTokens;
|
||||
private readonly double _temperature;
|
||||
private readonly ConcurrentDictionary<ulong, List<ChatMessage>> _histories = new();
|
||||
private const int MaxHistoryChars = 20000;
|
||||
private const int MaxToolIterations = 5;
|
||||
|
||||
public LlmService(IConfiguration configuration, ILogger<LlmService> logger, MemoryStore memoryStore, ToolRegistry toolRegistry)
|
||||
{
|
||||
_logger = logger;
|
||||
_httpClient = new HttpClient();
|
||||
_memoryStore = memoryStore;
|
||||
_toolRegistry = toolRegistry;
|
||||
|
||||
_endpoint = configuration["Llm:Endpoint"] ?? throw new InvalidOperationException("Llm:Endpoint is not configured");
|
||||
_apiKey = Environment.GetEnvironmentVariable("LLM_API_KEY")
|
||||
?? " ";
|
||||
_model = configuration["Llm:Model"] ?? throw new InvalidOperationException("Llm:Model is not configured");
|
||||
_maxTokens = int.Parse(configuration["Llm:MaxTokens"] ?? "1024");
|
||||
_temperature = double.Parse(configuration["Llm:Temperature"] ?? "0.7");
|
||||
|
||||
_httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _apiKey);
|
||||
_httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<string> SendMessageAsync(ulong channelId, ulong userId, string username, string message)
|
||||
{
|
||||
var history = _histories.GetOrAdd(channelId, _ => new List<ChatMessage>());
|
||||
|
||||
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);
|
||||
|
||||
var toolDefinitions = _toolRegistry.BuildToolDefinitions();
|
||||
var jsonOptions = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
for (int iteration = 0; iteration < MaxToolIterations; iteration++)
|
||||
{
|
||||
var request = new ChatRequest
|
||||
{
|
||||
Model = _model,
|
||||
Messages = CopyMessages(history),
|
||||
MaxTokens = _maxTokens,
|
||||
Temperature = (float)_temperature,
|
||||
Stream = false,
|
||||
Tools = toolDefinitions
|
||||
};
|
||||
|
||||
var requestJson = JsonSerializer.Serialize(request, jsonOptions);
|
||||
var httpContent = new StringContent(requestJson, Encoding.UTF8, "application/json");
|
||||
|
||||
using var response = await _httpClient.PostAsync(_endpoint, httpContent);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
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}");
|
||||
}
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
var chatResponse = JsonSerializer.Deserialize<ChatResponse>(body, jsonOptions);
|
||||
var choice = chatResponse?.Choices?.FirstOrDefault();
|
||||
|
||||
if (choice == null)
|
||||
{
|
||||
_logger.LogError("Empty response from LLM API");
|
||||
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;
|
||||
}
|
||||
|
||||
_logger.LogWarning("Max tool iterations reached without a final response");
|
||||
}
|
||||
|
||||
private static string? TryParseStreamToken(string data)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(data);
|
||||
|
||||
if (doc.RootElement.TryGetProperty("choices", out var choices) &&
|
||||
choices.ValueKind == JsonValueKind.Array &&
|
||||
choices.GetArrayLength() > 0)
|
||||
{
|
||||
var firstChoice = choices[0];
|
||||
|
||||
if (firstChoice.TryGetProperty("delta", out var delta) &&
|
||||
delta.ValueKind == JsonValueKind.Object &&
|
||||
delta.TryGetProperty("content", out var deltaContent) &&
|
||||
deltaContent.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return deltaContent.GetString();
|
||||
}
|
||||
|
||||
if (firstChoice.TryGetProperty("text", out var text) &&
|
||||
text.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return text.GetString();
|
||||
}
|
||||
}
|
||||
|
||||
if (doc.RootElement.TryGetProperty("delta", out var rootDelta) &&
|
||||
rootDelta.ValueKind == JsonValueKind.Object &&
|
||||
rootDelta.TryGetProperty("content", out var rootContent) &&
|
||||
rootContent.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return rootContent.GetString();
|
||||
}
|
||||
|
||||
if (doc.RootElement.TryGetProperty("content", out var content) &&
|
||||
content.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return content.GetString();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? TryParseNonStreamContent(string body)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
|
||||
if (doc.RootElement.TryGetProperty("choices", out var choices) &&
|
||||
choices.ValueKind == JsonValueKind.Array &&
|
||||
choices.GetArrayLength() > 0)
|
||||
{
|
||||
var firstChoice = choices[0];
|
||||
|
||||
if (firstChoice.TryGetProperty("message", out var message) &&
|
||||
message.ValueKind == JsonValueKind.Object &&
|
||||
message.TryGetProperty("content", out var messageContent) &&
|
||||
messageContent.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return messageContent.GetString();
|
||||
}
|
||||
|
||||
if (firstChoice.TryGetProperty("text", out var text) &&
|
||||
text.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return text.GetString();
|
||||
}
|
||||
}
|
||||
|
||||
if (doc.RootElement.TryGetProperty("content", out var content) &&
|
||||
content.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return content.GetString();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public void ResetHistory(ulong channelId)
|
||||
{
|
||||
_histories.TryRemove(channelId, out _);
|
||||
}
|
||||
|
||||
private void TrimHistory(List<ChatMessage> history)
|
||||
{
|
||||
while (history.Sum(m => m.Content.Length) > MaxHistoryChars && history.Count > 2)
|
||||
{
|
||||
history.RemoveAt(0);
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
public string Model { get; init; } = "";
|
||||
public List<ChatMessage> Messages { get; init; } = new();
|
||||
public int MaxTokens { get; init; }
|
||||
public float Temperature { 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 string Role { 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; }
|
||||
}
|
||||
}
|
||||
65
Services/Memory/DatabaseInitializer.cs
Normal file
65
Services/Memory/DatabaseInitializer.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
186
Services/Memory/MemoryStore.cs
Normal file
186
Services/Memory/MemoryStore.cs
Normal 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))
|
||||
);
|
||||
}
|
||||
}
|
||||
84
Services/PalworldApiService.cs
Normal file
84
Services/PalworldApiService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
200
Services/Tools/MemoryTools.cs
Normal file
200
Services/Tools/MemoryTools.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
55
Services/Tools/ToolRegistry.cs
Normal file
55
Services/Tools/ToolRegistry.cs
Normal 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);
|
||||
}
|
||||
@@ -11,10 +11,12 @@
|
||||
|
||||
<ItemGroup>
|
||||
<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.Json" 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.Http" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" Version="8.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
21
appsettings.json
Normal file
21
appsettings.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
services:
|
||||
tentaclebot:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: git.gpatti.com/gpatti/tentaclebot:latest
|
||||
container_name: tentaclebot
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- tentaclebot_data:/data
|
||||
|
||||
volumes:
|
||||
tentaclebot_data:
|
||||
|
||||
Reference in New Issue
Block a user