commit 76d2c8e6a070cc4619d722fae2b5582ba62475b0 Author: MrDevBot Date: Tue Sep 1 17:51:15 2026 +1000 Initial Beetlejuice Discord bot diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..cf8d841 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +**/bin +**/obj +tests +publish +.git +.env diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a3bf66b --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +DISCORD_TOKEN=replace-me +DISCORD_GUILD_ID=572498926788280340 +# Empty means any user in the configured guild; otherwise comma-separated role IDs. +DISCORD_ALLOWED_ROLE_IDS= + +RCON_HOST=zomboid +RCON_PORT=27015 +RCON_PASSWORD=replace-me + +MANUAL_RESTART_COOLDOWN_SECONDS=600 +AUTO_RESTART_INTERVAL_HOURS=6 +AUTO_RESTART_TIMEZONE=Australia/Brisbane +AUTO_RESTART_WARNING_SECONDS=600,300,60 +RESTART_READY_TIMEOUT_SECONDS=900 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..502367a --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +**/bin/ +**/obj/ +publish/ +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e6a50a2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS build +RUN apk add --no-cache clang build-base zlib-dev +WORKDIR /src +COPY src/ ./ +RUN dotnet publish -c Release -r linux-musl-x64 --self-contained true -o /out + +FROM alpine:3.23 +RUN apk add --no-cache ca-certificates tzdata libgcc libstdc++ +COPY --from=build /out/Beetlejuice /usr/local/bin/beetlejuice +USER 65532:65532 +ENTRYPOINT ["/usr/local/bin/beetlejuice"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..88a2b76 --- /dev/null +++ b/README.md @@ -0,0 +1,16 @@ +# Beetlejuice + +Native-AOT C# Discord bot. Not deployed. + +Commands: +- `/status` +- `/players` +- `/restart` + +Manual restart requires zero players and a 10-minute in-memory cooldown. Automatic restarts run every six hours with 10, 5, and 1 minute Discord/in-game warnings. + +The bot uses environment-provided Discord and 64-character RCON credentials. RCON is used for `players`, `servermsg`, and `quit`; Docker `restart: unless-stopped` starts the server again. + +```bash +dotnet publish src/Beetlejuice.csproj -c Release -r linux-x64 --self-contained true +``` diff --git a/compose.example.yml b/compose.example.yml new file mode 100644 index 0000000..6f43246 --- /dev/null +++ b/compose.example.yml @@ -0,0 +1,27 @@ +services: + beetlejuice: + image: ${BEETLEJUICE_IMAGE:?} + restart: unless-stopped + environment: + DISCORD_TOKEN: ${DISCORD_TOKEN:?} + DISCORD_GUILD_ID: ${DISCORD_GUILD_ID:-572498926788280340} + DISCORD_ALLOWED_ROLE_IDS: ${DISCORD_ALLOWED_ROLE_IDS:-} + RCON_HOST: ${RCON_HOST:-zomboid} + RCON_PORT: ${RCON_PORT:-27015} + RCON_PASSWORD: ${RCON_PASSWORD:?} + MANUAL_RESTART_COOLDOWN_SECONDS: ${MANUAL_RESTART_COOLDOWN_SECONDS:-600} + AUTO_RESTART_INTERVAL_HOURS: ${AUTO_RESTART_INTERVAL_HOURS:-6} + AUTO_RESTART_TIMEZONE: ${AUTO_RESTART_TIMEZONE:-Australia/Brisbane} + AUTO_RESTART_WARNING_SECONDS: ${AUTO_RESTART_WARNING_SECONDS:-600,300,60} + RESTART_READY_TIMEOUT_SECONDS: ${RESTART_READY_TIMEOUT_SECONDS:-900} + networks: [zomboid-control] + read_only: true + tmpfs: [/tmp] + cap_drop: [ALL] + security_opt: [no-new-privileges:true] + mem_limit: 128m + +networks: + zomboid-control: + external: true + name: zomboid-control diff --git a/src/Beetlejuice.csproj b/src/Beetlejuice.csproj new file mode 100644 index 0000000..8791824 --- /dev/null +++ b/src/Beetlejuice.csproj @@ -0,0 +1,19 @@ + + + Exe + Beetlejuice + Beetlejuice + net10.0 + enable + enable + true + true + true + + + + + + + + diff --git a/src/Program.cs b/src/Program.cs new file mode 100644 index 0000000..2b97c35 --- /dev/null +++ b/src/Program.cs @@ -0,0 +1,141 @@ +using System.Net; +using System.Text.RegularExpressions; +using CoreRCON; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using NetCord; +using NetCord.Gateway; +using NetCord.Hosting.Gateway; +using NetCord.Hosting.Services.ApplicationCommands; +using NetCord.Services.ApplicationCommands; + +string Env(string key, string? fallback = null) => Environment.GetEnvironmentVariable(key) ?? fallback + ?? throw new InvalidOperationException($"Missing {key}"); +string discordToken = Env("DISCORD_TOKEN"); +if (discordToken == "REPLACE_IN_COOLIFY") { Console.WriteLine("Set DISCORD_TOKEN in Coolify, then restart Beetlejuice."); await Task.Delay(Timeout.Infinite); return; } +ulong guildId = ulong.Parse(Env("DISCORD_GUILD_ID")); +var roles = Env("DISCORD_ALLOWED_ROLE_IDS", "").Split(',', StringSplitOptions.RemoveEmptyEntries).Select(ulong.Parse).ToHashSet(); +string rconPassword = Env("RCON_PASSWORD"); +if (rconPassword.Length != 64) throw new InvalidOperationException("RCON_PASSWORD must be 64 characters"); +var zone = TimeZoneInfo.FindSystemTimeZoneById(Env("AUTO_RESTART_TIMEZONE", "Australia/Brisbane")); +int interval = int.Parse(Env("AUTO_RESTART_INTERVAL_HOURS", "6")); +int[] warnings = Env("AUTO_RESTART_WARNING_SECONDS", "600,300,60").Split(',').Select(int.Parse).OrderDescending().ToArray(); +var cooldown = TimeSpan.FromSeconds(int.Parse(Env("MANUAL_RESTART_COOLDOWN_SECONDS", "600"))); +var readyTimeout = TimeSpan.FromSeconds(int.Parse(Env("RESTART_READY_TIMEOUT_SECONDS", "900"))); +var address = (await Dns.GetHostAddressesAsync(Env("RCON_HOST", "zomboid"))).First(x => x.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork); +using var rcon = new RCON(address, ushort.Parse(Env("RCON_PORT", "27015")), rconPassword, + strictCommandPacketIdMatching: false); +var gate = new SemaphoreSlim(1, 1); +DateTimeOffset? lastManual = null; + +var builder = Host.CreateApplicationBuilder(args); +builder.Services.AddDiscordGateway(o => { o.Token = discordToken; o.Intents = GatewayIntents.Guilds; }) + .AddApplicationCommands(); +var host = builder.Build(); + +bool Allowed(ApplicationCommandContext c) => c.Interaction.GuildId == guildId && c.User is GuildInteractionUser u && + (roles.Count == 0 || u.RoleIds.Any(roles.Contains)); +async Task Command(string command) => await rcon.SendCommandAsync(command); +async Task<(int Count, string[] Names)> Players() +{ + string output = await Command("players"); + var match = Regex.Match(output, @"Players\s+connected\s*(?:\((\d+)\)|:\s*(\d+))", RegexOptions.IgnoreCase); + if (!match.Success) throw new InvalidDataException("Unknown players response"); + int count = int.Parse(match.Groups[1].Success ? match.Groups[1].Value : match.Groups[2].Value); + string[] names = output.Replace("\r", "").Split('\n').Select(x => x.Trim()).Where(x => x.StartsWith('-')) + .Select(x => x.TrimStart('-', ' ')).Where(x => x.Length > 0).ToArray(); + return (count, names); +} +Task Broadcast(string message) => Command($"servermsg \"{message.Replace('"', '\'')}\""); +async Task Quit() +{ + try { await Command("quit"); } catch { if (rcon.Connected) throw; } +} +async Task Ready(string kind) +{ + bool stopped = false; + var deadline = DateTimeOffset.UtcNow + readyTimeout; + while (DateTimeOffset.UtcNow < deadline) + { + try { await Players(); if (stopped) { Console.WriteLine($"{kind} restart complete; server ready."); return; } } + catch { stopped = true; } + await Task.Delay(5000); + } + Console.Error.WriteLine($"{kind} restart timed out waiting for readiness."); +} +async Task Manual() +{ + if (!await gate.WaitAsync(0)) return "A restart is already in progress."; + bool release = true; + try + { + if (lastManual is { } last && DateTimeOffset.UtcNow - last < cooldown) return "Restart cooldown is active."; + int count = (await Players()).Count; + if (count == 0) count = (await Players()).Count; + if (count != 0) return $"Denied: {count} player(s) online."; + await Quit(); + lastManual = DateTimeOffset.UtcNow; + release = false; + _ = Task.Run(async () => { try { await Ready("Manual"); } finally { gate.Release(); } }); + return "Restart accepted."; + } + catch (Exception e) { return $"Denied: {e.Message}"; } + finally { if (release) gate.Release(); } +} +async Task Warn(int seconds) +{ + string text = $"Automatic server restart in {(seconds % 60 == 0 ? $"{seconds / 60} minute(s)" : $"{seconds} seconds")}."; + try { await Broadcast(text); } catch { } +} +async Task Automatic() +{ + if (!await gate.WaitAsync(0)) return; + try + { + try { await Broadcast("Automatic server restart now."); } catch { } + await Quit(); + await Ready("Automatic"); + } + catch (Exception e) { Console.Error.WriteLine($"Automatic restart failed: {e.Message}"); } + finally { gate.Release(); } +} +DateTimeOffset Next() +{ + DateTime local = TimeZoneInfo.ConvertTime(DateTimeOffset.UtcNow, zone).DateTime; + int hour = (local.Hour / interval + 1) * interval; + return new(TimeZoneInfo.ConvertTimeToUtc(DateTime.SpecifyKind(local.Date.AddHours(hour), DateTimeKind.Unspecified), zone)); +} +async Task Schedule(CancellationToken token) +{ + while (!token.IsCancellationRequested) + { + DateTimeOffset next = Next(); + foreach (int warning in warnings) + { + TimeSpan delay = next - DateTimeOffset.UtcNow - TimeSpan.FromSeconds(warning); + if (delay <= TimeSpan.Zero) continue; + await Task.Delay(delay, token); await Warn(warning); + } + TimeSpan delayToRestart = next - DateTimeOffset.UtcNow; + if (delayToRestart > TimeSpan.Zero) await Task.Delay(delayToRestart, token); + await Automatic(); + } +} + +host.AddSlashCommand("status", "Show server status", async (ApplicationCommandContext c) => + !Allowed(c) ? "Not authorised." : await Status()); +host.AddSlashCommand("restart", "Restart when no players are online", (ApplicationCommandContext c) => + Allowed(c) ? Manual() : Task.FromResult("Not authorised.")); +host.AddSlashCommand("players", "List online players", async (ApplicationCommandContext c) => + !Allowed(c) ? "Not authorised." : await PlayerList()); + +async Task Status() { try { return $"Server online; {(await Players()).Count} player(s)."; } catch { return "Server unavailable."; } } +async Task PlayerList() +{ + try { var p = await Players(); return p.Count == 0 ? "No players online." : $"Online ({p.Count}): {string.Join(", ", p.Names)}"; } + catch { return "Unable to query players."; } +} + +await host.StartAsync(); +_ = Schedule(host.Services.GetRequiredService().ApplicationStopping); +await host.WaitForShutdownAsync();