diff --git a/.env.example b/.env.example index 562eb11..9526660 100644 --- a/.env.example +++ b/.env.example @@ -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 \ No newline at end of file diff --git a/.gitea/workflows/docker-publish.yml b/.gitea/workflows/docker-publish.yml new file mode 100644 index 0000000..5189dfa --- /dev/null +++ b/.gitea/workflows/docker-publish.yml @@ -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 }} diff --git a/.gitignore b/.gitignore index 1a7a575..1ce2bc2 100644 --- a/.gitignore +++ b/.gitignore @@ -28,10 +28,6 @@ bld/ *.userosscache *.sln.docstates -# Configuration files with sensitive data -appsettings.json -!appsettings.*.json - # NuGet .nuget/ *.nupkg diff --git a/AGENTS.md b/AGENTS.md index dbd2c56..100e38a 100644 --- a/AGENTS.md +++ b/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`. 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. diff --git a/Commands/PalworldCommands.cs b/Commands/PalworldCommands.cs new file mode 100644 index 0000000..18f041b --- /dev/null +++ b/Commands/PalworldCommands.cs @@ -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 +{ + 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(); + 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; + } +} diff --git a/Program.cs b/Program.cs index f8ff836..4ed7191 100644 --- a/Program.cs +++ b/Program.cs @@ -32,7 +32,9 @@ var services = new ServiceCollection() UseCompiledLambda = true, })) .AddSingleton() - .AddSingleton(); + .AddSingleton() + .AddHttpClient() + .AddSingleton(); var serviceProvider = services.BuildServiceProvider(); diff --git a/Services/LlmService.cs b/Services/LlmService.cs index 525603f..0475c77 100644 --- a/Services/LlmService.cs +++ b/Services/LlmService.cs @@ -25,7 +25,8 @@ public class LlmService _httpClient = new HttpClient(); _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") + ?? throw new InvalidOperationException("LLM_API_KEY environment variable is not set"); _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"); diff --git a/Services/PalworldApiService.cs b/Services/PalworldApiService.cs new file mode 100644 index 0000000..b20160c --- /dev/null +++ b/Services/PalworldApiService.cs @@ -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 _logger; + private readonly string _baseUrl; + + public PalworldApiService( + IHttpClientFactory httpClientFactory, + IConfiguration configuration, + ILogger 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 GetInfoAsync() + { + return await GetAsync("info"); + } + + public async Task GetPlayersAsync() + { + return await GetAsync("players"); + } + + public async Task GetMetricsAsync() + { + return await GetAsync("metrics"); + } + + private async Task 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; + } + } +} diff --git a/TentacleBot.csproj b/TentacleBot.csproj index a0ef490..d3c9ec7 100644 --- a/TentacleBot.csproj +++ b/TentacleBot.csproj @@ -15,6 +15,7 @@ + diff --git a/appsettings.json b/appsettings.json new file mode 100644 index 0000000..22fa4ab --- /dev/null +++ b/appsettings.json @@ -0,0 +1,18 @@ +{ + "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" + } +} diff --git a/docker-compose.yml b/docker-compose.yml index a36bc47..37a1efe 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,6 @@ services: tentaclebot: + image: git.gpatti.com/gpatti/tentaclebot:latest build: context: . dockerfile: Dockerfile