Initial Beetlejuice Discord bot

This commit is contained in:
MrDevBot
2026-09-01 17:51:15 +10:00
commit 76d2c8e6a0
8 changed files with 238 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
**/bin
**/obj
tests
publish
.git
.env
+14
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
**/bin/
**/obj/
publish/
.env
+11
View File
@@ -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"]
+16
View File
@@ -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
```
+27
View File
@@ -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
+19
View File
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<AssemblyName>Beetlejuice</AssemblyName>
<RootNamespace>Beetlejuice</RootNamespace>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<PublishAot>true</PublishAot>
<InvariantGlobalization>true</InvariantGlobalization>
<StripSymbols>true</StripSymbols>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CoreRCON" Version="5.4.2" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.3" />
<PackageReference Include="NetCord.Hosting" Version="1.0.0-beta.18" />
<PackageReference Include="NetCord.Hosting.Services" Version="1.0.0-beta.18" />
</ItemGroup>
</Project>
+141
View File
@@ -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<string> 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<string> 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<string> Status() { try { return $"Server online; {(await Players()).Count} player(s)."; } catch { return "Server unavailable."; } }
async Task<string> 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<IHostApplicationLifetime>().ApplicationStopping);
await host.WaitForShutdownAsync();