Initial Beetlejuice Discord bot
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
**/bin
|
||||||
|
**/obj
|
||||||
|
tests
|
||||||
|
publish
|
||||||
|
.git
|
||||||
|
.env
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
**/bin/
|
||||||
|
**/obj/
|
||||||
|
publish/
|
||||||
|
.env
|
||||||
+11
@@ -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"]
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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();
|
||||||
Reference in New Issue
Block a user