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